summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ai-prompts/quality-engineer.org113
-rw-r--r--init.el16
-rw-r--r--modules/ai-conversations.el3
-rw-r--r--modules/browser-config.el81
-rw-r--r--modules/config-utilities.el1
-rw-r--r--modules/custom-comments.el666
-rw-r--r--modules/custom-file-buffer.el2
-rw-r--r--modules/custom-line-paragraph.el11
-rw-r--r--modules/custom-misc.el90
-rw-r--r--modules/custom-ordering.el228
-rw-r--r--modules/custom-text-enclose.el295
-rw-r--r--modules/custom-whitespace.el196
-rw-r--r--modules/flyspell-and-abbrev.el2
-rw-r--r--modules/font-config.el2
-rw-r--r--modules/jumper.el255
-rw-r--r--modules/lipsum-generator.el9
-rw-r--r--modules/lorem-optimum.el (renamed from modules/lorem-generator.el)102
-rw-r--r--modules/org-agenda-config.el2
-rw-r--r--modules/org-contacts-config.el155
-rw-r--r--modules/org-roam-config.el88
-rw-r--r--modules/org-webclipper.el80
-rw-r--r--modules/reconcile-open-repos.el1
-rw-r--r--modules/test-runner.el331
-rw-r--r--modules/wip.el1
-rw-r--r--tests/test-browser-config.el277
-rw-r--r--tests/test-clear-blank-lines.el.disabled47
-rw-r--r--tests/test-custom-comments-comment-block-banner.el228
-rw-r--r--tests/test-custom-comments-comment-box.el241
-rw-r--r--tests/test-custom-comments-comment-heavy-box.el251
-rw-r--r--tests/test-custom-comments-comment-inline-border.el235
-rw-r--r--tests/test-custom-comments-comment-padded-divider.el250
-rw-r--r--tests/test-custom-comments-comment-reformat.el191
-rw-r--r--tests/test-custom-comments-comment-simple-divider.el246
-rw-r--r--tests/test-custom-comments-comment-unicode-box.el264
-rw-r--r--tests/test-custom-comments-delete-buffer-comments.el224
-rw-r--r--tests/test-custom-file-buffer-clear-to-bottom-of-buffer.el163
-rw-r--r--tests/test-custom-file-buffer-clear-to-top-of-buffer.el162
-rw-r--r--tests/test-custom-file-buffer-copy-link-to-buffer-file.el209
-rw-r--r--tests/test-custom-file-buffer-copy-path-to-buffer-file-as-kill.el205
-rw-r--r--tests/test-custom-file-buffer-copy-whole-buffer.el194
-rw-r--r--tests/test-custom-misc-count-words.el148
-rw-r--r--tests/test-custom-misc-format-region.el161
-rw-r--r--tests/test-custom-misc-jump-to-matching-paren.el197
-rw-r--r--tests/test-custom-misc-replace-fraction-glyphs.el185
-rw-r--r--tests/test-custom-ordering-alphabetize.el176
-rw-r--r--tests/test-custom-ordering-arrayify.el215
-rw-r--r--tests/test-custom-ordering-comma-to-lines.el159
-rw-r--r--tests/test-custom-ordering-number-lines.el181
-rw-r--r--tests/test-custom-ordering-reverse-lines.el131
-rw-r--r--tests/test-custom-ordering-toggle-quotes.el155
-rw-r--r--tests/test-custom-ordering-unarrayify.el159
-rw-r--r--tests/test-custom-org-agenda-functions.el.disabled94
-rw-r--r--tests/test-custom-text-enclose-append.el190
-rw-r--r--tests/test-custom-text-enclose-indent.el241
-rw-r--r--tests/test-custom-text-enclose-prepend.el207
-rw-r--r--tests/test-custom-text-enclose-surround.el200
-rw-r--r--tests/test-custom-text-enclose-unwrap.el266
-rw-r--r--tests/test-custom-text-enclose-wrap.el240
-rw-r--r--tests/test-custom-whitespace-collapse.el150
-rw-r--r--tests/test-custom-whitespace-delete-all.el150
-rw-r--r--tests/test-custom-whitespace-delete-blank-lines.el146
-rw-r--r--tests/test-custom-whitespace-ensure-single-blank.el146
-rw-r--r--tests/test-custom-whitespace-hyphenate.el140
-rw-r--r--tests/test-custom-whitespace-remove-leading-trailing.el157
-rw-r--r--tests/test-fixup-whitespace.el.disabled159
-rw-r--r--tests/test-flyspell-config-functions.el.disabled149
-rw-r--r--tests/test-format-region.el.disabled110
-rw-r--r--tests/test-jumper.el352
-rw-r--r--tests/test-lorem-optimum-benchmark.el227
-rw-r--r--tests/test-lorem-optimum.el242
-rw-r--r--tests/test-org-contacts-parse-email.el219
-rw-r--r--tests/test-org-roam-config-demote.el183
-rw-r--r--tests/test-org-roam-config-format.el151
-rw-r--r--tests/test-org-roam-config-link-description.el188
-rw-r--r--tests/test-org-roam-config-slug.el223
-rw-r--r--tests/test-org-webclipper-process.el210
-rw-r--r--tests/test-test-runner.el359
-rw-r--r--tests/test-theme-theme-persistence.el.disabled135
-rw-r--r--tests/test-title-case-region.el.disabled44
-rw-r--r--tests/test-undead-buffers.el11
-rw-r--r--tests/testutil-general.el25
-rw-r--r--todo.org492
82 files changed, 12078 insertions, 1712 deletions
diff --git a/ai-prompts/quality-engineer.org b/ai-prompts/quality-engineer.org
index dde2538b..4aad0d19 100644
--- a/ai-prompts/quality-engineer.org
+++ b/ai-prompts/quality-engineer.org
@@ -55,6 +55,7 @@ Test failure scenarios ensuring appropriate error handling:
- Invalid inputs and type mismatches
- Out-of-range values
- Missing required parameters
+- Error messages are informative (test behavior, not exact wording)
- Resource limitations (memory, file handles)
- Security vulnerabilities (injection attacks, buffer overflows, XSS)
- Malformed or malicious input
@@ -110,6 +111,25 @@ For each test case, provide:
- Tests should exercise the actual parsing, transformation, or computation logic
- Rule of thumb: If the function body could be `(error "not implemented")` and tests still pass, you've over-mocked
+*** Testing Framework/Library Integration
+- When function primarily delegates to framework/library code, focus tests on YOUR integration logic
+- Don't extensively test the framework itself - trust it works
+- Example: Function that calls `comment-kill` should test:
+ - You call it with correct arguments ✓
+ - You set up context correctly (e.g., go to point-min) ✓
+ - You handle return values appropriately ✓
+ - NOT: That `comment-kill` works in 50 different scenarios ✗
+- For cross-language/cross-mode functionality:
+ - Test 2-3 representative modes to prove compatibility
+ - Don't test every possible mode - diminishing returns
+ - Group by similarity (e.g., C-style comments: C/Java/Go/JavaScript)
+ - Example distribution:
+ - 15 tests in primary mode (all edge/boundary/error cases)
+ - 3 tests each in 2 other modes (just prove different syntaxes work)
+ - Total: ~21 tests instead of 100+
+- Document testing approach in test file Commentary
+- Balance: Prove polyglot functionality without excessive duplication
+
*** Performance Testing
- Establish baseline performance metrics
- Test with realistic data volumes
@@ -128,12 +148,105 @@ For each test case, provide:
- Use version control to track test evolution
- Maintain a regression test suite
+*** Error Message Testing
+- Production code should provide clear error messages with context
+ - Include what operation failed, why it failed, and what to do
+ - Help users understand where the error originated
+- Tests should verify error behavior, not exact message text
+ - Test that errors occur (should-error, returns nil, etc.)
+ - Avoid asserting exact message wording unless critical to behavior
+ - Example: Test that function returns nil, not that message contains "not visiting"
+- When message content matters, test structure not exact text
+ - Use regexp patterns for key information (e.g., filename must be present)
+ - Test message type/severity, not specific phrasing
+- Balance: Ensure appropriate feedback exists without coupling to implementation
+
+*** Interactive vs Non-Interactive Function Pattern
+When writing functions that combine business logic with user interaction:
+- Split into internal implementation and interactive wrapper
+- Internal function (prefix with ~--~): Pure logic, takes all parameters explicitly
+ - Example: ~(defun cj/--move-buffer-and-file (dir &optional ok-if-exists) ...)~
+ - Deterministic, testable, reusable by other code
+ - No interactive prompts, no UI logic
+- Interactive wrapper: Thin layer handling only user interaction
+ - Example: ~(defun cj/move-buffer-and-file (dir) ...)~
+ - Prompts user for input, handles confirmations
+ - Catches errors and prompts for retry if needed
+ - Delegates all business logic to internal function
+- Test the internal function with direct parameter values
+ - No mocking ~yes-or-no-p~, ~read-directory-name~, etc.
+ - Simple, deterministic, fast tests
+ - Optional: Add minimal tests for interactive wrapper behavior
+- Benefits:
+ - Dramatically simpler testing (no interactive mocking)
+ - Code reusable programmatically without prompts
+ - Clear separation of concerns (logic vs UI)
+ - Follows standard Emacs patterns
+
*** Test Maintenance
- Refactor tests alongside production code
- Remove obsolete tests
- Update tests when requirements change
- Keep test code DRY (but prefer clarity over brevity)
+*** Refactor vs Rewrite Decision Framework
+When inheriting untested code that needs testing, evaluate whether to refactor or rewrite:
+
+**** Key Decision Factors
+- **Similarity to recently-written code**: If you just wrote similar logic, adapting it is lower risk than refactoring old code
+- **Knowledge freshness**: Recently-implemented patterns are fresh in mind, reducing rewrite risk
+- **Code complexity**: Complex old code may be riskier to refactor than to rewrite from a working template
+- **Testing strategy**: If testing requires extensive mocking, that's a signal the code should be refactored
+- **Uniqueness of logic**: Unique algorithms with no templates favor refactoring; common patterns favor rewriting
+- **Time investment**: Compare actual effort, not perceived effort
+
+**** When to Refactor
+Prefer refactoring when:
+- Logic is unique with no similar working implementation to adapt
+- Code is relatively simple and well-structured
+- You don't have a tested template to work from
+- Risk of missing edge cases is high
+- Code is already mostly correct, just needs structural improvements
+
+Example: Refactoring a centering algorithm with unique spacing calculations
+
+**** When to Rewrite
+Prefer rewriting when:
+- You JUST wrote and tested similar functionality (knowledge is fresh!)
+- A working, tested template exists that can be adapted
+- Old code is overly complex or convoluted
+- Rewriting ensures consistency with recent patterns
+- Old code has poor validation or error handling
+
+Example: Adapting a 5-line box function you just tested into a 3-line variant
+
+**** Hybrid Approaches
+Often optimal to mix strategies:
+- Refactor unique logic without templates
+- Rewrite similar logic by adapting recent work
+- Evaluate each function independently based on its specific situation
+
+**** The "Knowledge Freshness" Principle
+**Critical insight**: Code you wrote in the last few hours/days is dramatically easier to adapt than old code, even if the old code seems "simpler." The mental model is loaded, edge cases are fresh, and patterns are internalized. This makes rewriting from recent work LOWER RISK than it appears.
+
+Example timeline:
+- Day 1: Write and test heavy-box (5 lines, centered text)
+- Day 1 later: Need regular box (3 lines, centered text)
+- **Adapt heavy-box** (lower risk) vs **refactor old box** (higher risk despite seeming simpler)
+
+**** Red Flags Indicating Rewrite Over Refactor
+- Code is impossible to test without extensive mocking
+- Mixing of concerns (UI + business logic intertwined)
+- No validation or poor error handling
+- You just finished implementing the same pattern elsewhere
+- Code quality is significantly below current standards
+
+**** Document Your Decision
+- When choosing refactor vs rewrite, document reasoning
+- Note which factors were most important
+- Track actual time spent vs estimated
+- Learn from outcomes for future decisions
+
## Workflow & Communication
*** When to Generate Tests
diff --git a/init.el b/init.el
index b1e05b29..2cc27f38 100644
--- a/init.el
+++ b/init.el
@@ -31,7 +31,6 @@
(require 'custom-datetime) ;; date/timestamp insertion in various formats
(require 'custom-file-buffer) ;; custom buffer and file operations and keymap
(require 'custom-line-paragraph) ;; operations on lines and paragraphs
-;; <<<< writing tests for custom-misc
(require 'custom-misc) ;; miscellaneous functions
(require 'custom-ordering) ;; ordering and sorting operations
(require 'custom-text-enclose) ;; operations to append, prepend, and surround text
@@ -42,10 +41,11 @@
;; ------------------------- System Level Functionality ------------------------
(require 'auth-config) ;; emacs gnupg integration
-(require 'keyboard-macros) ;; tested: keyboard macro management
+(require 'keyboard-macros) ;; keyboard macro management
(require 'system-utils) ;; timers, process monitor
(require 'text-config) ;; text settings and functionality
-(require 'undead-buffers) ;; bury rather than kill specific buffers
+(require 'undead-buffers) ;; bury rather than kill buffers you choose
+(require 'browser-config) ;; browser configuration/integration
;; ------------------------ User Interface Configuration -----------------------
@@ -83,7 +83,7 @@
(require 'markdown-config)
(require 'pdf-config) ;; pdf display settings
(require 'quick-video-capture) ;; desktop and/or audio recording via ffmpeg
-(require 'show-kill-ring) ;; displays and facilitates pasting from history
+;; (require 'show-kill-ring) ;; displays and facilitates pasting from history
(require 'video-audio-recording) ;; desktop and/or audio recording via ffmpeg
(require 'weather-config) ;; utility to display the weather
@@ -128,7 +128,7 @@
;; Filesystem Tools
(require 'read_text_file)
(require 'write_text_file)
-;; (require 'update_text_file) ;; BUG: issues with this tool
+ ;; (require 'update_text_file) ;; BUG: issues with this tool
(require 'list_directory_files)
(require 'move_to_trash))
@@ -143,10 +143,10 @@
(require 'games-config)
;; ------------------------------ Modules In Test ------------------------------
-(require 'browser-config)
+
;;(require 'wip)
-;;(require 'lipsum-generator)
-;;(require 'jumper)
+(require 'lorem-optimum)
+(require 'jumper)
;; ---------------------------------- Wrap Up ----------------------------------
diff --git a/modules/ai-conversations.el b/modules/ai-conversations.el
index 92549176..4f97d761 100644
--- a/modules/ai-conversations.el
+++ b/modules/ai-conversations.el
@@ -159,7 +159,6 @@ Expect FILENAME to match _YYYYMMDD-HHMMSS.gptel."
(or (get-buffer buf-name)
(user-error "Could not create or find *AI-Assistant* buffer"))))
-;;;###autoload
(defun cj/gptel-save-conversation ()
"Save the current AI-Assistant buffer to a .gptel file.
@@ -188,7 +187,6 @@ Enable autosave for subsequent AI responses to the same file."
(setq-local cj/gptel-autosave-enabled t))
(message "Conversation saved to: %s" filepath))))
-;;;###autoload
(defun cj/gptel-delete-conversation ()
"Delete a saved GPTel conversation file (chronologically sorted candidates)."
(interactive)
@@ -218,7 +216,6 @@ Enable autosave for subsequent AI responses to the same file."
(when (looking-at "^\n+")
(delete-region (point) (match-end 0)))))
-;;;###autoload
(defun cj/gptel-load-conversation ()
"Load a saved GPTel conversation into the AI-Assistant buffer.
diff --git a/modules/browser-config.el b/modules/browser-config.el
index fddc02e6..52c3b8a6 100644
--- a/modules/browser-config.el
+++ b/modules/browser-config.el
@@ -80,19 +80,44 @@ Returns the browser plist if found, nil otherwise."
cj/saved-browser-choice))
(error nil))))
-(defun cj/apply-browser-choice (browser-plist)
- "Apply the browser settings from BROWSER-PLIST."
- (when browser-plist
+(defun cj/--do-apply-browser-choice (browser-plist)
+ "Apply the browser settings from BROWSER-PLIST.
+Returns: \\='success if applied successfully,
+ \\='invalid-plist if browser-plist is nil or missing required keys."
+ (if (null browser-plist)
+ 'invalid-plist
(let ((browse-fn (plist-get browser-plist :function))
(executable (plist-get browser-plist :executable))
(path (plist-get browser-plist :path))
(program-var (plist-get browser-plist :program-var)))
- ;; Set the main browse-url function
- (setq browse-url-browser-function browse-fn)
- ;; Set the specific browser program variable if it exists
- (when program-var
- (set program-var (or path executable)))
- (message "Default browser set to: %s" (plist-get browser-plist :name)))))
+ (if (null browse-fn)
+ 'invalid-plist
+ ;; Set the main browse-url function
+ (setq browse-url-browser-function browse-fn)
+ ;; Set the specific browser program variable if it exists
+ (when program-var
+ (set program-var (or path executable)))
+ 'success))))
+
+(defun cj/apply-browser-choice (browser-plist)
+ "Apply the browser settings from BROWSER-PLIST."
+ (pcase (cj/--do-apply-browser-choice browser-plist)
+ ('success (message "Default browser set to: %s" (plist-get browser-plist :name)))
+ ('invalid-plist (message "Invalid browser configuration"))))
+
+(defun cj/--do-choose-browser (browser-plist)
+ "Save and apply BROWSER-PLIST as the default browser.
+Returns: \\='success if browser was saved and applied,
+ \\='save-failed if save operation failed,
+ \\='invalid-plist if browser-plist is invalid."
+ (condition-case _err
+ (progn
+ (cj/save-browser-choice browser-plist)
+ (let ((result (cj/--do-apply-browser-choice browser-plist)))
+ (if (eq result 'success)
+ 'success
+ 'invalid-plist)))
+ (error 'save-failed)))
(defun cj/choose-browser ()
"Interactively choose a browser from available options.
@@ -107,21 +132,39 @@ Persists the choice for future sessions."
(string= (plist-get b :name) choice))
browsers)))
(when selected
- (cj/save-browser-choice selected)
- (cj/apply-browser-choice selected))))))
+ (pcase (cj/--do-choose-browser selected)
+ ('success (message "Default browser set to: %s" (plist-get selected :name)))
+ ('save-failed (message "Failed to save browser choice"))
+ ('invalid-plist (message "Invalid browser configuration"))))))))
;; Initialize: Load saved choice or use first available browser
-(defun cj/initialize-browser ()
- "Initialize browser configuration on startup."
+(defun cj/--do-initialize-browser ()
+ "Initialize browser configuration.
+Returns: (cons \\='loaded browser-plist) if saved choice was loaded,
+ (cons \\='first-available browser-plist) if using first discovered browser,
+ (cons \\='no-browsers nil) if no browsers found."
(let ((saved-choice (cj/load-browser-choice)))
(if saved-choice
- (cj/apply-browser-choice saved-choice)
- ;; No saved choice - try to set first available browser silently
+ (cons 'loaded saved-choice)
+ ;; No saved choice - try to set first available browser
(let ((browsers (cj/discover-browsers)))
- (when browsers
- (cj/apply-browser-choice (car browsers))
- (message "No browser configured. Using %s. Run M-x cj/choose-browser to change."
- (plist-get (car browsers) :name)))))))
+ (if browsers
+ (cons 'first-available (car browsers))
+ (cons 'no-browsers nil))))))
+
+(defun cj/initialize-browser ()
+ "Initialize browser configuration on startup."
+ (let ((result (cj/--do-initialize-browser)))
+ (pcase (car result)
+ ('loaded
+ (cj/--do-apply-browser-choice (cdr result)))
+ ('first-available
+ (let ((browser (cdr result)))
+ (cj/--do-apply-browser-choice browser)
+ (message "No browser configured. Using %s. Run M-x cj/choose-browser to change."
+ (plist-get browser :name))))
+ ('no-browsers
+ (message "No supported browsers found")))))
;; Run initialization
(cj/initialize-browser)
diff --git a/modules/config-utilities.el b/modules/config-utilities.el
index d1538256..ea92f19a 100644
--- a/modules/config-utilities.el
+++ b/modules/config-utilities.el
@@ -108,7 +108,6 @@ Recompile natively when supported, otherwise fall back to byte compilation."
(find-lisp-find-files user-emacs-directory ""))
(message "Done. Compiled files removed under %s" user-emacs-directory))
(keymap-set cj/debug-config-keymap "c d" 'cj/delete-emacs-home-compiled-files)
-(keymap-set cj/debug-config-keymap "c d" 'cj/delete-emacs-home-compiled-files)
(defun cj/compile-this-elisp-buffer ()
"Compile the current .el: prefer native (.eln), else .elc. Message if neither."
diff --git a/modules/custom-comments.el b/modules/custom-comments.el
index 101ba092..b4e51b2c 100644
--- a/modules/custom-comments.el
+++ b/modules/custom-comments.el
@@ -13,11 +13,50 @@
;; These utilities help create consistent, well-formatted code comments and section headers.
;; Bound to keymap prefix: C-; C
;;
+;; Comment Style Patterns:
+;;
+;; inline-border:
+;; ========== inline-border ==========
+;;
+;; simple-divider:
+;; ====================================
+;; simple-divider
+;; ====================================
+;;
+;; padded-divider:
+;; ====================================
+;; padded-divider
+;; ====================================
+;;
+;; box:
+;; ************************************
+;; * box *
+;; ************************************
+;;
+;; heavy-box:
+;; ************************************
+;; * *
+;; * heavy-box *
+;; * *
+;; ************************************
+;;
+;; unicode-box:
+;; ┌──────────────────────────────────┐
+;; │ unicode-box │
+;; └──────────────────────────────────┘
+;;
+;; block-banner:
+;; /************************************
+;; * block-banner
+;; ************************************/
+;;
;;; Code:
(eval-when-compile (defvar cj/custom-keymap)) ;; cj/custom-keymap defined in keybindings.el
(autoload 'cj/join-line-or-region "custom-line-paragraph" nil t)
+;; ======================== Comment Manipulation Functions =====================
+
;; --------------------------- Delete Buffer Comments --------------------------
(defun cj/delete-buffer-comments ()
@@ -38,146 +77,545 @@
(orig-fill-column fill-column))
(uncomment-region beg end)
(setq fill-column (- fill-column 3))
- (cj/join-line-or-region beg end)
+ (cj/join-line-or-region)
(comment-region beg end)
(setq fill-column orig-fill-column )))
;; if no region
(message "No region was selected. Select the comment lines to reformat."))
-;; ------------------------------ Comment Centered -----------------------------
+;; ======================== Comment Generation Functions =======================
+
+;; ----------------------------- Inline Border ---------------------------------
+
+(defun cj/--comment-inline-border (cmt-start cmt-end decoration-char text length)
+ "Internal implementation: Generate single-line centered comment with decoration.
+CMT-START and CMT-END are the comment syntax strings.
+DECORATION-CHAR is the character to use for borders (string).
+TEXT is the comment text (will be centered).
+LENGTH is the total width of the line."
+ (let* ((current-column-pos (current-column))
+ (text-length (length text))
+ (comment-start-len (+ (length cmt-start)
+ (if (equal cmt-start ";") 1 0))) ; doubled semicolon
+ ;; Calculate available space for decoration + text + spaces
+ (available-width (- length current-column-pos
+ comment-start-len
+ (if (string-empty-p cmt-end) 0 (1+ (length cmt-end)))
+ 1)) ; space after comment-start
+ ;; Space for decoration on each side (excluding text and its surrounding spaces)
+ (space-on-each-side (/ (- available-width
+ text-length
+ (if (> text-length 0) 2 0)) ; spaces around text
+ 2))
+ (min-space 2))
+ ;; Validate we have enough space
+ (when (< space-on-each-side min-space)
+ (error "Length %d is too small for text '%s' (need at least %d more chars)"
+ length text (- min-space space-on-each-side)))
+ ;; Generate the line
+ (insert cmt-start)
+ (when (equal cmt-start ";")
+ (insert cmt-start))
+ (insert " ")
+ ;; Left decoration
+ (dotimes (_ space-on-each-side)
+ (insert decoration-char))
+ ;; Text with spaces
+ (when (> text-length 0)
+ (insert " " text " "))
+ ;; Right decoration (handle odd-length text)
+ (dotimes (_ (if (= (% text-length 2) 0)
+ (- space-on-each-side 1)
+ space-on-each-side))
+ (insert decoration-char))
+ ;; Comment end
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline)))
+
+(defun cj/comment-inline-border (&optional decoration-char)
+ "Insert single-line comment with TEXT centered around DECORATION-CHAR borders.
+DECORATION-CHAR defaults to \"#\" if not provided.
+Uses the lesser of `fill-column\\=' or 80 for line length."
+ (interactive)
+ (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
+ comment-start
+ (read-string "Comment start character(s): ")))
+ (comment-end (if (and (boundp 'comment-end) comment-end)
+ comment-end
+ ""))
+ (decoration-char (or decoration-char "#"))
+ (text (capitalize (string-trim (read-from-minibuffer "Comment: "))))
+ (length (min fill-column 80)))
+ (cj/--comment-inline-border comment-start comment-end decoration-char text length)))
+
+;; ---------------------------- Simple Divider ---------------------------------
+
+(defun cj/--comment-simple-divider (cmt-start cmt-end decoration-char text length)
+ "Internal implementation: Generate a simple divider comment.
+CMT-START and CMT-END are the comment syntax strings.
+DECORATION-CHAR is the character to use for the divider lines.
+TEXT is the comment text.
+LENGTH is the total width of each line."
+ (let* ((current-column-pos (current-column))
+ (min-length (+ current-column-pos
+ (length cmt-start)
+ (if (equal cmt-start ";") 1 0) ; doubled semicolon
+ 1 ; space after comment-start
+ 3 ; minimum decoration chars
+ (if (string-empty-p cmt-end) 0 (1+ (length cmt-end))))))
+ (when (< length min-length)
+ (error "Length %d is too small to generate comment (minimum %d)" length min-length))
+ (let* ((available-width (- length current-column-pos
+ (length cmt-start)
+ (if (string-empty-p cmt-end) 0 (1+ (length cmt-end)))))
+ (line (make-string available-width (string-to-char decoration-char))))
+ ;; Top line
+ (insert cmt-start)
+ (when (equal cmt-start ";") (insert cmt-start))
+ (insert " ")
+ (insert line)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline)
+
+ ;; Text line
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert cmt-start)
+ (when (equal cmt-start ";") (insert cmt-start))
+ (insert " " text)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline)
+
+ ;; Bottom line
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert cmt-start)
+ (when (equal cmt-start ";") (insert cmt-start))
+ (insert " ")
+ (insert line)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline))))
+
+(defun cj/comment-simple-divider ()
+ "Insert a simple divider comment banner.
+Prompts for decoration character, text, and length option."
+ (interactive)
+ (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
+ comment-start
+ (read-string "Comment start character(s): ")))
+ (comment-end (if (and (boundp 'comment-end) comment-end)
+ comment-end
+ ""))
+ (decoration-char (read-string "Decoration character (default =): " nil nil "="))
+ (text (read-string "Comment text: "))
+ (length-option (completing-read "Length: "
+ '("fill-column" "half-column" "match-text")
+ nil t nil nil "fill-column"))
+ (length (cond
+ ((string= length-option "fill-column") fill-column)
+ ((string= length-option "half-column") (/ fill-column 2))
+ ((string= length-option "match-text")
+ (+ (length comment-start)
+ (if (equal comment-start ";") 1 0)
+ 1 ; space after comment-start
+ (length text)
+ (if (string-empty-p comment-end) 0 (1+ (length comment-end))))))))
+ (cj/--comment-simple-divider comment-start comment-end decoration-char text length)))
+
+;; ---------------------------- Padded Divider ---------------------------------
+
+(defun cj/--comment-padded-divider (cmt-start cmt-end decoration-char text length padding)
+ "Internal implementation: Generate a padded divider comment.
+CMT-START and CMT-END are the comment syntax strings.
+DECORATION-CHAR is the character to use for the divider lines.
+TEXT is the comment text.
+LENGTH is the total width of each line.
+PADDING is the number of spaces before the text."
+ (when (< padding 0)
+ (error "Padding %d cannot be negative" padding))
+ (let* ((current-column-pos (current-column))
+ (min-length (+ current-column-pos
+ (length cmt-start)
+ (if (equal cmt-start ";") 1 0) ; doubled semicolon
+ 1 ; space after comment-start
+ 3 ; minimum decoration chars
+ (if (string-empty-p cmt-end) 0 (1+ (length cmt-end))))))
+ (when (< length min-length)
+ (error "Length %d is too small to generate comment (minimum %d)" length min-length))
+ (let* ((available-width (- length current-column-pos
+ (length cmt-start)
+ (if (string-empty-p cmt-end) 0 (1+ (length cmt-end)))))
+ (line (make-string available-width (string-to-char decoration-char))))
+ ;; Top line
+ (insert cmt-start)
+ (when (equal cmt-start ";") (insert cmt-start))
+ (insert " ")
+ (insert line)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline)
+
+ ;; Text line with padding
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert cmt-start)
+ (when (equal cmt-start ";") (insert cmt-start))
+ (insert " ")
+ (dotimes (_ padding) (insert " "))
+ (insert text)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline)
+
+ ;; Bottom line
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert cmt-start)
+ (when (equal cmt-start ";") (insert cmt-start))
+ (insert " ")
+ (insert line)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline))))
-(defun cj/comment-centered (&optional comment-char)
- "Insert comment text centered around the COMMENT-CHAR character.
-Default to the hash character when COMMENT-CHAR is nil.
-Use the lesser of `fill-column' or 80 to calculate the comment length.
-Begin and end line with the appropriate comment symbols for the current mode."
+(defun cj/comment-padded-divider ()
+ "Insert a padded divider comment banner.
+Prompts for decoration character, text, padding, and length option."
(interactive)
- (if (not (char-or-string-p comment-char))
- (setq comment-char "#"))
- (let* ((comment (capitalize (string-trim (read-from-minibuffer "Comment: "))))
- (fill-column (min fill-column 80))
- (comment-length (length comment))
- ;; (comment-start-length (length comment-start))
- ;; (comment-end-length (length comment-end))
- (current-column-pos (current-column))
- (space-on-each-side (/ (- fill-column
- current-column-pos
- comment-length
- (length comment-start)
- (length comment-end)
- ;; Single space on each side of comment
- (if (> comment-length 0) 2 0)
- ;; Single space after comment syntax sting
- 1)
- 2)))
- (if (< space-on-each-side 2)
- (message "Comment string is too big to fit in one line")
- (progn
- (insert comment-start)
- (when (equal comment-start ";") ;; emacs-lisp line comments are ';;'
- (insert comment-start)) ;; so insert comment-char again
- (insert " ")
- (dotimes (_ space-on-each-side) (insert comment-char))
- (when (> comment-length 0) (insert " "))
- (insert comment)
- (when (> comment-length 0) (insert " "))
- (dotimes (_ (if (= (% comment-length 2) 0)
- (- space-on-each-side 1)
- space-on-each-side))
- (insert comment-char))
- ;; Only insert trailing space and comment-end if comment-end is not empty
- (when (not (string-empty-p comment-end))
- (insert " ")
- (insert comment-end))))))
+ (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
+ comment-start
+ (read-string "Comment start character(s): ")))
+ (comment-end (if (and (boundp 'comment-end) comment-end)
+ comment-end
+ ""))
+ (decoration-char (read-string "Decoration character (default =): " nil nil "="))
+ (text (read-string "Comment text: "))
+ (padding (string-to-number (read-string "Padding spaces (default 2): " nil nil "2")))
+ (length-option (completing-read "Length: "
+ '("fill-column" "half-column" "match-text")
+ nil t nil nil "fill-column"))
+ (length (cond
+ ((string= length-option "fill-column") fill-column)
+ ((string= length-option "half-column") (/ fill-column 2))
+ ((string= length-option "match-text")
+ (+ (length comment-start)
+ (if (equal comment-start ";") 1 0)
+ 1 ; space after comment-start
+ padding
+ (length text)
+ (if (string-empty-p comment-end) 0 (1+ (length comment-end))))))))
+ (cj/--comment-padded-divider comment-start comment-end decoration-char text length padding)))
;; -------------------------------- Comment Box --------------------------------
+(defun cj/--comment-box (cmt-start cmt-end decoration-char text length)
+ "Internal implementation: Generate a 3-line box comment with centered text.
+CMT-START and CMT-END are the comment syntax strings.
+DECORATION-CHAR is the character to use for borders.
+TEXT is the comment text (centered).
+LENGTH is the total width of each line."
+ (let* ((current-column-pos (current-column))
+ (comment-char (if (equal cmt-start ";") ";;" cmt-start))
+ (comment-end-char (if (string-empty-p cmt-end) comment-char cmt-end))
+ (min-length (+ current-column-pos
+ (length comment-char)
+ 2 ; spaces around content
+ (length comment-end-char)
+ 6))) ; minimum: 3 border chars + text space + 3 border chars
+ (when (< length min-length)
+ (error "Length %d is too small to generate comment (minimum %d)" length min-length))
+ (let* ((available-width (- length current-column-pos
+ (length comment-char)
+ (length comment-end-char)
+ 2)) ; spaces around content
+ (border-line (make-string available-width (string-to-char decoration-char)))
+ (text-length (length text))
+ ;; For text line: need space for decoration + space + text + space + decoration
+ (text-available (- available-width 4)) ; 2 for side decorations, 2 for spaces
+ (padding-each-side (max 1 (/ (- text-available text-length) 2)))
+ (right-padding (if (= (% (- text-available text-length) 2) 0)
+ padding-each-side
+ (1+ padding-each-side))))
+ ;; Top border
+ (insert comment-char " " border-line " " comment-end-char)
+ (newline)
+
+ ;; Centered text line with side borders
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert comment-char " " decoration-char " ")
+ (dotimes (_ padding-each-side) (insert " "))
+ (insert text)
+ (dotimes (_ right-padding) (insert " "))
+ (insert " " decoration-char " " comment-end-char)
+ (newline)
+
+ ;; Bottom border
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert comment-char " " border-line " " comment-end-char)
+ (newline))))
+
(defun cj/comment-box ()
- "Insert a comment box around text that the user inputs.
-The box extends to the fill column, centers the text, and uses the current
-mode's comment syntax at both the beginning and end of each line. The box
-respects the current indentation level and avoids trailing whitespace."
+ "Insert a 3-line comment box with centered text.
+Prompts for decoration character, text, and uses `fill-column' for length."
+ (interactive)
+ (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
+ comment-start
+ (read-string "Comment start character(s): ")))
+ (comment-end (if (and (boundp 'comment-end) comment-end)
+ comment-end
+ ""))
+ (decoration-char (read-string "Decoration character (default -): " nil nil "-"))
+ (text (capitalize (string-trim (read-from-minibuffer "Comment: "))))
+ (length (min fill-column 80)))
+ (cj/--comment-box comment-start comment-end decoration-char text length)))
+
+;; ------------------------------ Heavy Box ------------------------------------
+
+(defun cj/--comment-heavy-box (cmt-start cmt-end decoration-char text length)
+ "Internal implementation: Generate a heavy box comment with blank lines.
+CMT-START and CMT-END are the comment syntax strings.
+DECORATION-CHAR is the character to use for borders.
+TEXT is the comment text (centered).
+LENGTH is the total width of each line."
+ (let* ((current-column-pos (current-column))
+ (comment-char (if (equal cmt-start ";") ";;" cmt-start))
+ (comment-end-char (if (string-empty-p cmt-end) comment-char cmt-end))
+ (available-width (- length current-column-pos
+ (length comment-char)
+ (length comment-end-char)
+ 2)) ; spaces around content
+ (border-line (make-string available-width (string-to-char decoration-char)))
+ (text-length (length text))
+ (padding-each-side (max 1 (/ (- available-width text-length) 2)))
+ (right-padding (if (= (% (- available-width text-length) 2) 0)
+ padding-each-side
+ (1+ padding-each-side))))
+ ;; Top border
+ (insert comment-char " " border-line " " comment-end-char)
+ (newline)
+
+ ;; Empty line with side borders
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert decoration-char)
+ (dotimes (_ available-width) (insert " "))
+ (insert " " decoration-char)
+ (newline)
+
+ ;; Centered text line
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert decoration-char " ")
+ (dotimes (_ padding-each-side) (insert " "))
+ (insert text)
+ (dotimes (_ right-padding) (insert " "))
+ (insert " " decoration-char)
+ (newline)
+
+ ;; Empty line with side borders
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert decoration-char)
+ (dotimes (_ available-width) (insert " "))
+ (insert " " decoration-char)
+ (newline)
+
+ ;; Bottom border
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert comment-char " " border-line " " comment-end-char)
+ (newline)))
+
+(defun cj/comment-heavy-box ()
+ "Insert a heavy box comment with blank lines around centered text.
+Prompts for decoration character, text, and length option."
+ (interactive)
+ (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
+ comment-start
+ (read-string "Comment start character(s): ")))
+ (comment-end (if (and (boundp 'comment-end) comment-end)
+ comment-end
+ ""))
+ (decoration-char (read-string "Decoration character (default *): " nil nil "*"))
+ (text (read-string "Comment text: "))
+ (length-option (completing-read "Length: "
+ '("fill-column" "half-column" "padded-text")
+ nil t nil nil "fill-column"))
+ (length (cond
+ ((string= length-option "fill-column") fill-column)
+ ((string= length-option "half-column") (/ fill-column 2))
+ ((string= length-option "padded-text")
+ (+ (current-column)
+ (length (if (equal comment-start ";") ";;" comment-start))
+ 2 ; decoration char + space
+ 4 ; minimum padding (2 on each side)
+ (length text)
+ (if (string-empty-p comment-end)
+ 1 ; just the side decoration
+ (1+ (length comment-end))))))))
+ (cj/--comment-heavy-box comment-start comment-end decoration-char text length)))
+
+;; ---------------------------- Unicode Box ------------------------------------
+
+(defun cj/--comment-unicode-box (cmt-start cmt-end text length box-style)
+ "Internal implementation: Generate a unicode box comment.
+CMT-START and CMT-END are the comment syntax strings.
+TEXT is the comment text.
+LENGTH is the total width of each line.
+BOX-STYLE is either \\='single or \\='double for line style."
+ (let* ((current-column-pos (current-column))
+ (comment-char (if (equal cmt-start ";") ";;" cmt-start))
+ (min-length (+ current-column-pos
+ (length comment-char)
+ 1 ; space after comment-char
+ 5 ; minimum: corner + corner + padding
+ (if (string-empty-p cmt-end) 0 (1+ (length cmt-end))))))
+ (when (< length min-length)
+ (error "Length %d is too small to generate comment (minimum %d)" length min-length))
+ (let* ((available-width (- length current-column-pos
+ (length comment-char)
+ (if (string-empty-p cmt-end) 0 (1+ (length cmt-end)))
+ 3)) ; box corners and padding
+ (top-left (if (eq box-style 'double) "╔" "┌"))
+ (top-right (if (eq box-style 'double) "╗" "┐"))
+ (bottom-left (if (eq box-style 'double) "╚" "└"))
+ (bottom-right (if (eq box-style 'double) "╝" "┘"))
+ (horizontal (if (eq box-style 'double) "═" "─"))
+ (vertical (if (eq box-style 'double) "║" "│"))
+ (text-padding (- available-width (length text) 2)))
+ ;; Top line
+ (insert comment-char " " top-left)
+ (dotimes (_ available-width) (insert horizontal))
+ (insert top-right)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline)
+
+ ;; Text line
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert comment-char " " vertical " " text)
+ (dotimes (_ text-padding) (insert " "))
+ (insert " " vertical)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline)
+
+ ;; Bottom line
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert comment-char " " bottom-left)
+ (dotimes (_ available-width) (insert horizontal))
+ (insert bottom-right)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline))))
+
+(defun cj/comment-unicode-box ()
+ "Insert a unicode box comment.
+Prompts for text, box style, and length option."
+ (interactive)
+ (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
+ comment-start
+ (read-string "Comment start character(s): ")))
+ (comment-end (if (and (boundp 'comment-end) comment-end)
+ comment-end
+ ""))
+ (text (read-string "Comment text: "))
+ (box-style (intern (completing-read "Box style: "
+ '("single" "double")
+ nil t nil nil "single")))
+ (length-option (completing-read "Length: "
+ '("fill-column" "half-column" "padded-text")
+ nil t nil nil "fill-column"))
+ (length (cond
+ ((string= length-option "fill-column") fill-column)
+ ((string= length-option "half-column") (/ fill-column 2))
+ ((string= length-option "padded-text")
+ (+ (current-column)
+ (length (if (equal comment-start ";") ";;" comment-start))
+ 5 ; box chars and spaces
+ (length text)
+ (if (string-empty-p comment-end) 0 (1+ (length comment-end))))))))
+ (cj/--comment-unicode-box comment-start comment-end text length box-style)))
+
+;; ---------------------------- Block Banner -----------------------------------
+
+(defun cj/--comment-block-banner (cmt-start cmt-end decoration-char text length)
+ "Internal implementation: Generate a block banner comment (JSDoc/Doxygen style).
+CMT-START should be the block comment start (e.g., '/*').
+CMT-END should be the block comment end (e.g., '*/').
+DECORATION-CHAR is the character to use for the border line.
+TEXT is the comment text.
+LENGTH is the total width of each line."
+ (let* ((current-column-pos (current-column))
+ (min-length (+ current-column-pos
+ (length cmt-start)
+ 3))) ; minimum: 3 decoration chars
+ (when (< length min-length)
+ (error "Length %d is too small to generate comment (minimum %d)" length min-length))
+ (let* ((available-width (- length current-column-pos (length cmt-start) 1))
+ (border-line (make-string available-width (string-to-char decoration-char))))
+ ;; Top line
+ (insert cmt-start border-line)
+ (newline)
+
+ ;; Text line
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert " " decoration-char " " text)
+ (newline)
+
+ ;; Bottom line
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert " ")
+ (dotimes (_ (- available-width (length cmt-end)))
+ (insert decoration-char))
+ (insert cmt-end)
+ (newline))))
+
+(defun cj/comment-block-banner ()
+ "Insert a block banner comment (JSDoc/Doxygen style).
+Prompts for decoration character, text, and length option."
(interactive)
- (let* ((comment-char (if (equal comment-start ";") ";;"
- (string-trim comment-start)))
- (comment-end-char (if (string-empty-p comment-end)
- comment-char
- (string-trim comment-end)))
- (line-char (if (equal comment-char ";;") "-" "#"))
- (comment (capitalize (string-trim (read-from-minibuffer "Comment: "))))
- (comment-length (length comment))
- (current-column-pos (current-column))
- (max-width (min fill-column 80))
- ;; Calculate available width between comment markers
- (available-width (- max-width
- current-column-pos
- (length comment-char)
- (length comment-end-char)))
- ;; Inner width is the width without the spaces after comment start and before comment end
- (inner-width (- available-width 2))
- ;; Calculate padding for each side of the centered text
- (padding-each-side (max 1 (/ (- inner-width comment-length) 2)))
- ;; Adjust for odd-length comments
- (right-padding (if (= (% (- inner-width comment-length) 2) 0)
- padding-each-side
- (1+ padding-each-side))))
-
- ;; Check if we have enough space
- (if (< inner-width (+ comment-length 4)) ; minimum sensible width
- (message "Comment string is too big to fit in one line")
- (progn
- ;; Top line - fill entirely with line characters except for space after comment start
- (insert comment-char)
- (insert " ")
- (insert (make-string inner-width (string-to-char line-char)))
- (insert " ")
- (insert comment-end-char)
- (newline)
-
- ;; Add indentation on the new line to match current column
- (dotimes (_ current-column-pos) (insert " "))
-
- ;; Middle line with centered text
- (insert comment-char)
- (insert " ")
- ;; Left padding
- (dotimes (_ padding-each-side) (insert " "))
- ;; The comment text
- (insert comment)
- ;; Right padding
- (dotimes (_ right-padding) (insert " "))
- (insert " ")
- (insert comment-end-char)
- (newline)
-
- ;; Add indentation on the new line to match current column
- (dotimes (_ current-column-pos) (insert " "))
-
- ;; Bottom line - same as top line
- (insert comment-char)
- (insert " ")
- (dotimes (_ inner-width) (insert line-char))
- (insert " ")
- (insert comment-end-char)
- (newline)))))
+ (let* ((comment-start (if (and (boundp 'comment-start) comment-start
+ (string-match-p "/\\*" comment-start))
+ comment-start
+ (read-string "Block comment start (e.g., /*): " nil nil "/*")))
+ (comment-end (if (and (boundp 'comment-end) comment-end
+ (not (string-empty-p comment-end)))
+ comment-end
+ (read-string "Block comment end (e.g., */): " nil nil "*/")))
+ (decoration-char (read-string "Decoration character (default *): " nil nil "*"))
+ (text (read-string "Comment text: "))
+ (length-option (completing-read "Length: "
+ '("fill-column" "half-column" "match-text")
+ nil t nil nil "fill-column"))
+ (length (cond
+ ((string= length-option "fill-column") fill-column)
+ ((string= length-option "half-column") (/ fill-column 2))
+ ((string= length-option "match-text")
+ (+ (current-column)
+ (length comment-start)
+ 2 ; space + decoration
+ (length text))))))
+ (cj/--comment-block-banner comment-start comment-end decoration-char text length)))
;; ------------------------------- Comment Hyphen ------------------------------
(defun cj/comment-hyphen()
"Insert a centered comment with `-' (hyphens) on each side.
-Leverages cj/comment-centered."
+Leverages cj/comment-inline-border."
(interactive)
- (cj/comment-centered "-"))
+ (cj/comment-inline-border "-"))
;; ------------------------------- Comment Keymap ------------------------------
(defvar-keymap cj/comment-map
:doc "Keymap for code comment operations"
"r" #'cj/comment-reformat
- "c" #'cj/comment-centered
+ "d" #'cj/delete-buffer-comments
+ "c" #'cj/comment-inline-border
"-" #'cj/comment-hyphen
+ "s" #'cj/comment-simple-divider
+ "p" #'cj/comment-padded-divider
"b" #'cj/comment-box
- "D" #'cj/delete-buffer-comments)
+ "h" #'cj/comment-heavy-box
+ "u" #'cj/comment-unicode-box
+ "n" #'cj/comment-block-banner)
(keymap-set cj/custom-keymap "C" cj/comment-map)
(with-eval-after-load 'which-key
diff --git a/modules/custom-file-buffer.el b/modules/custom-file-buffer.el
index a56edf18..e0224a32 100644
--- a/modules/custom-file-buffer.el
+++ b/modules/custom-file-buffer.el
@@ -108,7 +108,7 @@ Returns t on success, nil if buffer not visiting a file."
When called interactively, prompts for confirmation if target file exists."
(interactive (list (read-directory-name "Move buffer and file (to new directory): ")))
(let* ((target (expand-file-name (buffer-name) (expand-file-name dir))))
- (condition-case err
+ (condition-case _
(cj/--move-buffer-and-file dir nil)
(file-already-exists
(if (yes-or-no-p (format "File %s exists; overwrite? " target))
diff --git a/modules/custom-line-paragraph.el b/modules/custom-line-paragraph.el
index 17b6cdf4..7f0baef9 100644
--- a/modules/custom-line-paragraph.el
+++ b/modules/custom-line-paragraph.el
@@ -1,14 +1,13 @@
;;; custom-line-paragraph.el --- -*- coding: utf-8; lexical-binding: t; -*-
-
+;; Author: Craig Jennings <c@cjennings.net>
+;;
;;; Commentary:
;;
-;; This module provides line and paragraph manipulation utilities.
-;; These utilities enhance text editing and formatting capabilities.
+;; This module provides the following line and paragraph manipulation utilities:
;;
-;; Functions include:
;; - joining lines in a region or the current line with the previous one
-;; - joining entire paragraphs into single lines
-;; - duplicating lines or regions (with optional commenting)
+;; - joining separate lines into a single paragraph
+;; - duplicating lines or regions (optional commenting)
;; - removing duplicate lines
;; - removing lines containing specific text
;; - underlining text with a custom character
diff --git a/modules/custom-misc.el b/modules/custom-misc.el
index 0c6d7ac8..2af9c244 100644
--- a/modules/custom-misc.el
+++ b/modules/custom-misc.el
@@ -46,19 +46,27 @@ If not on a delimiter, show a message. Respects the current syntax table."
(message "Point is not on a delimiter.")))))
+(defun cj/--format-region (start end)
+ "Internal implementation: Reformat text between START and END.
+START and END define the region to operate on.
+Replaces tabs with spaces, reindents, and deletes trailing whitespace."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (save-excursion
+ (save-restriction
+ (narrow-to-region start end)
+ (untabify (point-min) (point-max))
+ (indent-region (point-min) (point-max))
+ (delete-trailing-whitespace (point-min) (point-max)))))
+
(defun cj/format-region-or-buffer ()
"Reformat the region or the entire buffer.
Replaces tabs with spaces, deletes trailing whitespace, and reindents."
(interactive)
(let ((start-pos (if (use-region-p) (region-beginning) (point-min)))
- (end-pos (if (use-region-p) (region-end) (point-max))))
- (save-excursion
- (save-restriction
- (narrow-to-region start-pos end-pos)
- (untabify (point-min) (point-max))
- (indent-region (point-min) (point-max))
- (delete-trailing-whitespace (point-min) (point-max))))
- (message "Formatted %s" (if (use-region-p) "region" "buffer"))))
+ (end-pos (if (use-region-p) (region-end) (point-max))))
+ (cj/--format-region start-pos end-pos)
+ (message "Formatted %s" (if (use-region-p) "region" "buffer"))))
(defun cj/switch-to-previous-buffer ()
"Switch to previously open buffer.
@@ -66,6 +74,14 @@ Repeated invocations toggle between the two most recently open buffers."
(interactive)
(switch-to-buffer (other-buffer (current-buffer) 1)))
+(defun cj/--count-words (start end)
+ "Internal implementation: Count words between START and END.
+START and END define the region to count.
+Returns the word count as an integer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (count-words start end))
+
(defun cj/count-words-buffer-or-region ()
"Count the number of words in the buffer or region.
Display the result in the minibuffer."
@@ -73,9 +89,38 @@ Display the result in the minibuffer."
(let* ((use-region (use-region-p))
(begin (if use-region (region-beginning) (point-min)))
(end (if use-region (region-end) (point-max)))
- (area-type (if use-region "the region" "the buffer")))
- (message "There are %d words in %s." (count-words begin end) area-type)))
+ (area-type (if use-region "the region" "the buffer"))
+ (word-count (cj/--count-words begin end)))
+ (message "There are %d words in %s." word-count area-type)))
+
+(defun cj/--replace-fraction-glyphs (start end to-glyphs)
+ "Internal implementation: Replace fraction glyphs or text between START and END.
+START and END define the region to operate on.
+TO-GLYPHS when non-nil converts text (1/4) to glyphs (¼),
+otherwise converts glyphs to text."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (let ((replacements (if to-glyphs
+ '(("1/4" . "¼")
+ ("1/2" . "½")
+ ("3/4" . "¾")
+ ("1/3" . "⅓")
+ ("2/3" . "⅔"))
+ '(("¼" . "1/4")
+ ("½" . "1/2")
+ ("¾" . "3/4")
+ ("⅓" . "1/3")
+ ("⅔" . "2/3"))))
+ (count 0)
+ (end-marker (copy-marker end)))
+ (save-excursion
+ (dolist (r replacements)
+ (goto-char start)
+ (while (search-forward (car r) end-marker t)
+ (replace-match (cdr r))
+ (setq count (1+ count)))))
+ count))
(defun cj/replace-fraction-glyphs (start end)
"Replace common fraction glyphs between START and END.
@@ -83,27 +128,10 @@ Operate on the buffer or region designated by START and END.
Replace the text representations with glyphs when called with a
\\[universal-argument] prefix."
(interactive (if (use-region-p)
- (list (region-beginning) (region-end))
- (list (point-min) (point-max))))
- (let ((replacements (if current-prefix-arg
- '(("1/4" . "¼")
- ("1/2" . "½")
- ("3/4" . "¾")
- ("1/3" . "⅓")
- ("2/3" . "⅔"))
- '(("¼" . "1/4")
- ("½" . "1/2")
- ("¾" . "3/4")
- ("⅓" . "1/3")
- ("⅔" . "2/3"))))
- (count 0))
- (save-excursion
- (dolist (r replacements)
- (goto-char start)
- (while (search-forward (car r) end t)
- (replace-match (cdr r))
- (setq count (1+ count)))))
- (message "Replaced %d fraction%s" count (if (= count 1) "" "s"))))
+ (list (region-beginning) (region-end))
+ (list (point-min) (point-max))))
+ (let ((count (cj/--replace-fraction-glyphs start end current-prefix-arg)))
+ (message "Replaced %d fraction%s" count (if (= count 1) "" "s"))))
(defun cj/align-regexp-with-spaces (orig-fun &rest args)
"Call ORIG-FUN with ARGS while temporarily disabling tabs for alignment.
diff --git a/modules/custom-ordering.el b/modules/custom-ordering.el
index 5d308604..abc9995a 100644
--- a/modules/custom-ordering.el
+++ b/modules/custom-ordering.el
@@ -2,47 +2,197 @@
;;; Commentary:
-;; This module provides functions for converting text between different formats and sorting operations.
-;; These utilities are useful for reformatting data structures and organizing text.
-
-;; Functions include:
-
-;; - converting lines to quoted comma-separated arrays (arrayify)
-;; - converting arrays back to separate lines (unarrayify)
-;; - alphabetically sorting words in a region
-;; - splitting comma-separated text into individual lines
-
+;; Text transformation and sorting utilities for reformatting data structures.
+;;
+;; Array/list formatting:
+;; - arrayify/listify - convert lines to comma-separated format (with/without quotes, brackets)
+;; - unarrayify - convert arrays back to separate lines
+;;
+;; Line manipulation:
+;; - toggle-quotes - swap double ↔ single quotes
+;; - reverse-lines - reverse line order
+;; - number-lines - add line numbers with custom format (supports zero-padding)
+;; - alphabetize-region - sort words alphabetically
+;; - comma-separated-text-to-lines - split CSV text into lines
+;;
+;; Convenience functions: listify, arrayify-json, arrayify-python
;; Bound to keymap prefix C-; o
;;; Code:
+(require 'cl-lib)
+
;; cj/custom-keymap defined in keybindings.el
(eval-when-compile (defvar cj/custom-keymap))
(defvar cj/ordering-map)
+(defun cj/--arrayify (start end quote &optional prefix suffix)
+ "Internal implementation: Convert lines to quoted, comma-separated format.
+START and END define the region to operate on.
+QUOTE specifies the quotation characters to surround each element.
+ Use \"\" for no quotes, \"\\\"\" for double quotes, \"'\" for single quotes.
+PREFIX is an optional string to prepend to the result (e.g., \"[\" or \"(\").
+SUFFIX is an optional string to append to the result (e.g., \"]\" or \")\").
+Returns the transformed string without modifying the buffer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (let ((result (mapconcat
+ (lambda (x) (format "%s%s%s" quote x quote))
+ (split-string (buffer-substring start end)) ", ")))
+ (concat (or prefix "") result (or suffix ""))))
+
(defun cj/arrayify (start end quote)
"Convert lines between START and END into quoted, comma-separated strings.
START and END identify the active region.
QUOTE specifies the quotation characters to surround each element."
(interactive "r\nMQuotation character to use for array element: ")
- (let ((insertion
- (mapconcat
- (lambda (x) (format "%s%s%s" quote x quote))
- (split-string (buffer-substring start end)) ", ")))
+ (let ((insertion (cj/--arrayify start end quote)))
(delete-region start end)
(insert insertion)))
+(defun cj/listify (start end)
+ "Convert lines between START and END into an unquoted, comma-separated list.
+START and END identify the active region.
+Example: `apple banana cherry' becomes `apple, banana, cherry'."
+ (interactive "r")
+ (let ((insertion (cj/--arrayify start end "")))
+ (delete-region start end)
+ (insert insertion)))
+
+(defun cj/arrayify-json (start end)
+ "Convert lines between START and END into a JSON-style array.
+START and END identify the active region.
+Example: `apple banana cherry' becomes `[\"apple\", \"banana\", \"cherry\"]'."
+ (interactive "r")
+ (let ((insertion (cj/--arrayify start end "\"" "[" "]")))
+ (delete-region start end)
+ (insert insertion)))
+
+(defun cj/arrayify-python (start end)
+ "Convert lines between START and END into a Python-style list.
+START and END identify the active region.
+Example: `apple banana cherry' becomes `[\"apple\", \"banana\", \"cherry\"]'."
+ (interactive "r")
+ (let ((insertion (cj/--arrayify start end "\"" "[" "]")))
+ (delete-region start end)
+ (insert insertion)))
+
+(defun cj/--unarrayify (start end)
+ "Internal implementation: Convert comma-separated array to lines.
+START and END define the region to operate on.
+Removes quotes (both single and double) and splits by ', '.
+Returns the transformed string without modifying the buffer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (mapconcat
+ (lambda (x) (replace-regexp-in-string "[\"']" "" x))
+ (split-string (buffer-substring start end) ", ") "\n"))
+
(defun cj/unarrayify (start end)
"Convert quoted comma-separated strings between START and END to separate lines.
START and END identify the active region."
(interactive "r")
- (let ((insertion
- (mapconcat
- (lambda (x) (replace-regexp-in-string "[\"']" "" x))
- (split-string (buffer-substring start end) ", ") "\n")))
+ (let ((insertion (cj/--unarrayify start end)))
(delete-region start end)
(insert insertion)))
+(defun cj/--toggle-quotes (start end)
+ "Internal implementation: Toggle between double and single quotes.
+START and END define the region to operate on.
+Swaps all double quotes with single quotes and vice versa.
+Returns the transformed string without modifying the buffer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (let ((text (buffer-substring start end)))
+ (with-temp-buffer
+ (insert text)
+ (goto-char (point-min))
+ ;; Use a placeholder to avoid double-swapping
+ (while (search-forward "\"" nil t)
+ (replace-match "\001" nil t))
+ (goto-char (point-min))
+ (while (search-forward "'" nil t)
+ (replace-match "\"" nil t))
+ (goto-char (point-min))
+ (while (search-forward "\001" nil t)
+ (replace-match "'" nil t))
+ (buffer-string))))
+
+(defun cj/toggle-quotes (start end)
+ "Toggle between double and single quotes in region between START and END.
+START and END identify the active region."
+ (interactive "r")
+ (let ((insertion (cj/--toggle-quotes start end)))
+ (delete-region start end)
+ (insert insertion)))
+
+(defun cj/--reverse-lines (start end)
+ "Internal implementation: Reverse the order of lines in region.
+START and END define the region to operate on.
+Returns the transformed string without modifying the buffer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (let ((lines (split-string (buffer-substring start end) "\n")))
+ (mapconcat #'identity (nreverse lines) "\n")))
+
+(defun cj/reverse-lines (start end)
+ "Reverse the order of lines in region between START and END.
+START and END identify the active region."
+ (interactive "r")
+ (let ((insertion (cj/--reverse-lines start end)))
+ (delete-region start end)
+ (insert insertion)))
+
+(defun cj/--number-lines (start end format-string zero-pad)
+ "Internal implementation: Number lines in region with custom format.
+START and END define the region to operate on.
+FORMAT-STRING is the format for each line, with N as placeholder for number.
+ Example: \"N. \" produces \"1. \", \"2. \", etc.
+ Example: \"[N] \" produces \"[1] \", \"[2] \", etc.
+ZERO-PAD when non-nil pads numbers with zeros for alignment.
+ Example with 100 lines: \"001\", \"002\", ..., \"100\".
+Returns the transformed string without modifying the buffer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (let* ((lines (split-string (buffer-substring start end) "\n"))
+ (line-count (length lines))
+ (width (if zero-pad (length (number-to-string line-count)) 1))
+ (format-spec (if zero-pad (format "%%0%dd" width) "%d")))
+ (mapconcat
+ (lambda (pair)
+ (let* ((num (car pair))
+ (line (cdr pair))
+ (num-str (format format-spec num)))
+ (concat (replace-regexp-in-string "N" num-str format-string) line)))
+ (cl-loop for line in lines
+ for i from 1
+ collect (cons i line))
+ "\n")))
+
+(defun cj/number-lines (start end format-string zero-pad)
+ "Number lines in region between START and END with custom format.
+START and END identify the active region.
+FORMAT-STRING is the format for each line, with N as placeholder for number.
+ Example: \"N. \" produces \"1. \", \"2. \", etc.
+ZERO-PAD when non-nil (prefix argument) pads numbers with zeros."
+ (interactive "r\nMFormat string (use N for number): \nP")
+ (let ((insertion (cj/--number-lines start end format-string zero-pad)))
+ (delete-region start end)
+ (insert insertion)))
+
+(defun cj/--alphabetize-region (start end)
+ "Internal implementation: Alphabetize words in region.
+START and END define the region to operate on.
+Splits by whitespace and commas, sorts alphabetically, joins with ', '.
+Returns the transformed string without modifying the buffer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (let ((string (buffer-substring-no-properties start end)))
+ (mapconcat #'identity
+ (sort (split-string string "[[:space:],]+" t)
+ #'string-lessp)
+ ", ")))
+
(defun cj/alphabetize-region ()
"Alphabetize words in the active region and replace the original text.
Produce a comma-separated list as the result."
@@ -51,14 +201,26 @@ Produce a comma-separated list as the result."
(user-error "No region selected"))
(let ((start (region-beginning))
(end (region-end))
- (string (buffer-substring-no-properties (region-beginning) (region-end))))
+ (insertion (cj/--alphabetize-region (region-beginning) (region-end))))
(delete-region start end)
(goto-char start)
- (insert
- (mapconcat #'identity
- (sort (split-string string "[[:space:],]+" t)
- #'string-lessp)
- ", "))))
+ (insert insertion)))
+
+(defun cj/--comma-separated-text-to-lines (start end)
+ "Internal implementation: Convert comma-separated text to lines.
+START and END define the region to operate on.
+Replaces commas with newlines and removes trailing whitespace from each line.
+Returns the transformed string without modifying the buffer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (let ((text (buffer-substring-no-properties start end)))
+ (with-temp-buffer
+ (insert text)
+ (goto-char (point-min))
+ (while (search-forward "," nil t)
+ (replace-match "\n" nil t))
+ (delete-trailing-whitespace)
+ (buffer-string))))
(defun cj/comma-separated-text-to-lines ()
"Break up comma-separated text in active region so each item is on own line."
@@ -68,15 +230,7 @@ Produce a comma-separated list as the result."
(let ((beg (region-beginning))
(end (region-end))
- (text (buffer-substring-no-properties (region-beginning) (region-end))))
- (with-temp-buffer
- (insert text)
- (goto-char (point-min))
- (while (search-forward "," nil t)
- (replace-match "\n" nil t))
- (delete-trailing-whitespace)
- (setq text (buffer-string)))
-
+ (text (cj/--comma-separated-text-to-lines (region-beginning) (region-end))))
(delete-region beg end)
(goto-char beg)
(insert text)))
@@ -88,8 +242,14 @@ Produce a comma-separated list as the result."
:doc "Keymap for text ordering and sorting operations"
"a" #'cj/arrayify
"u" #'cj/unarrayify
+ "l" #'cj/listify
+ "j" #'cj/arrayify-json
+ "p" #'cj/arrayify-python
+ "q" #'cj/toggle-quotes
+ "r" #'cj/reverse-lines
+ "n" #'cj/number-lines
"A" #'cj/alphabetize-region
- "l" #'cj/comma-separated-text-to-lines)
+ "L" #'cj/comma-separated-text-to-lines)
(keymap-set cj/custom-keymap "o" cj/ordering-map)
(with-eval-after-load 'which-key
diff --git a/modules/custom-text-enclose.el b/modules/custom-text-enclose.el
index 514419cd..ccacdd2d 100644
--- a/modules/custom-text-enclose.el
+++ b/modules/custom-text-enclose.el
@@ -2,78 +2,269 @@
;;; Commentary:
-;; This module provides functions to surround words or regions with custom strings, and to append or prepend text to lines.
+;; Text enclosure utilities for wrapping and line manipulation.
+;;
+;; Wrapping functions:
+;; - surround-word-or-region - wrap text with same delimiter on both sides
+;; - wrap-word-or-region - wrap with different opening/closing delimiters
+;; - unwrap-word-or-region - remove surrounding delimiters
+;;
+;; Line manipulation:
+;; - append-to-lines - add suffix to each line
+;; - prepend-to-lines - add prefix to each line
+;; - indent-lines - add leading whitespace (spaces or tabs)
+;; - dedent-lines - remove leading whitespace
+;;
+;; Most functions work on region or entire buffer when no region is active.
+;;
+;; Bound to keymap prefix C-; s
-;; It includes three main functions:
-;; - surround word or region with a user-specified string
-;; - append text to the end of lines
-;; - prepend text to the beginning of lines
+;;; Code:
-;; All functions work on both the active region and the entire buffer when no region is selected.
+;; cj/custom-keymap defined in keybindings.el
+(eval-when-compile (defvar cj/custom-keymap))
-;; Bound to keymap prefix C-; s
+(defun cj/--surround (text surround-string)
+ "Internal implementation: Surround TEXT with SURROUND-STRING.
+TEXT is the string to be surrounded.
+SURROUND-STRING is prepended and appended to TEXT.
+Returns the surrounded text without modifying the buffer."
+ (concat surround-string text surround-string))
-;;; Code:
+(defun cj/--wrap (text opening closing)
+ "Internal implementation: Wrap TEXT with OPENING and CLOSING strings.
+TEXT is the string to be wrapped.
+OPENING is prepended to TEXT.
+CLOSING is appended to TEXT.
+Returns the wrapped text without modifying the buffer."
+ (concat opening text closing))
(defun cj/surround-word-or-region ()
- "Surround the word at point or active region with a string read from the minibuffer."
+ "Surround the word at point or active region with a string.
+The surround string is read from the minibuffer."
(interactive)
(let ((str (read-string "Surround with: "))
(regionp (use-region-p)))
- (save-excursion
- (if regionp
- (let ((beg (region-beginning))
- (end (region-end)))
- (goto-char end)
- (insert str)
- (goto-char beg)
- (insert str))
- (if (thing-at-point 'word)
- (let ((bounds (bounds-of-thing-at-point 'word)))
- (goto-char (cdr bounds))
- (insert str)
- (goto-char (car bounds))
- (insert str))
- (message "Can't insert around. No word at point and no region selected."))))))
+ (if regionp
+ (let ((beg (region-beginning))
+ (end (region-end))
+ (text (buffer-substring (region-beginning) (region-end))))
+ (delete-region beg end)
+ (goto-char beg)
+ (insert (cj/--surround text str)))
+ (if (thing-at-point 'word)
+ (let* ((bounds (bounds-of-thing-at-point 'word))
+ (text (buffer-substring (car bounds) (cdr bounds))))
+ (delete-region (car bounds) (cdr bounds))
+ (goto-char (car bounds))
+ (insert (cj/--surround text str)))
+ (message "Can't insert around. No word at point and no region selected.")))))
+
+(defun cj/wrap-word-or-region ()
+ "Wrap the word at point or active region with different opening/closing strings.
+The opening and closing strings are read from the minibuffer."
+ (interactive)
+ (let ((opening (read-string "Opening: "))
+ (closing (read-string "Closing: "))
+ (regionp (use-region-p)))
+ (if regionp
+ (let ((beg (region-beginning))
+ (end (region-end))
+ (text (buffer-substring (region-beginning) (region-end))))
+ (delete-region beg end)
+ (goto-char beg)
+ (insert (cj/--wrap text opening closing)))
+ (if (thing-at-point 'word)
+ (let* ((bounds (bounds-of-thing-at-point 'word))
+ (text (buffer-substring (car bounds) (cdr bounds))))
+ (delete-region (car bounds) (cdr bounds))
+ (goto-char (car bounds))
+ (insert (cj/--wrap text opening closing)))
+ (message "Can't wrap. No word at point and no region selected.")))))
+
+(defun cj/--unwrap (text opening closing)
+ "Internal implementation: Remove OPENING and CLOSING from TEXT if present.
+TEXT is the string to unwrap.
+OPENING is checked at the start of TEXT.
+CLOSING is checked at the end of TEXT.
+Returns the unwrapped text if both delimiters present, otherwise unchanged."
+ (if (and (string-prefix-p opening text)
+ (string-suffix-p closing text)
+ (>= (length text) (+ (length opening) (length closing))))
+ (substring text (length opening) (- (length text) (length closing)))
+ text))
+
+(defun cj/unwrap-word-or-region ()
+ "Remove surrounding delimiters from word at point or active region.
+The opening and closing strings are read from the minibuffer."
+ (interactive)
+ (let ((opening (read-string "Opening to remove: "))
+ (closing (read-string "Closing to remove: "))
+ (regionp (use-region-p)))
+ (if regionp
+ (let ((beg (region-beginning))
+ (end (region-end))
+ (text (buffer-substring (region-beginning) (region-end))))
+ (delete-region beg end)
+ (goto-char beg)
+ (insert (cj/--unwrap text opening closing)))
+ (if (thing-at-point 'word)
+ (let* ((bounds (bounds-of-thing-at-point 'word))
+ (text (buffer-substring (car bounds) (cdr bounds))))
+ (delete-region (car bounds) (cdr bounds))
+ (goto-char (car bounds))
+ (insert (cj/--unwrap text opening closing)))
+ (message "Can't unwrap. No word at point and no region selected.")))))
+
+(defun cj/--append-to-lines (text suffix)
+ "Internal implementation: Append SUFFIX to each line in TEXT.
+TEXT is the string containing one or more lines.
+SUFFIX is appended to the end of each line.
+Returns the transformed string without modifying the buffer."
+ (let* ((lines (split-string text "\n"))
+ (has-trailing-newline (string-suffix-p "\n" text))
+ ;; If has trailing newline, last element will be empty string - exclude it
+ (lines-to-process (if (and has-trailing-newline
+ (not (null lines))
+ (string-empty-p (car (last lines))))
+ (butlast lines)
+ lines)))
+ (concat
+ (mapconcat (lambda (line) (concat line suffix)) lines-to-process "\n")
+ (if has-trailing-newline "\n" ""))))
(defun cj/append-to-lines-in-region-or-buffer (str)
"Append STR to the end of each line in the region or entire buffer."
(interactive "sEnter string to append: ")
- (let ((start-pos (if (use-region-p)
- (region-beginning)
- (point-min)))
- (end-pos (if (use-region-p)
- (region-end)
- (point-max))))
- (save-excursion
- (goto-char start-pos)
- (while (< (point) end-pos)
- (move-end-of-line 1)
- (insert str)
- (forward-line 1)))))
+ (let* ((start-pos (if (use-region-p)
+ (region-beginning)
+ (point-min)))
+ (end-pos (if (use-region-p)
+ (region-end)
+ (point-max)))
+ (text (buffer-substring start-pos end-pos))
+ (insertion (cj/--append-to-lines text str)))
+ (delete-region start-pos end-pos)
+ (goto-char start-pos)
+ (insert insertion)))
+
+(defun cj/--prepend-to-lines (text prefix)
+ "Internal implementation: Prepend PREFIX to each line in TEXT.
+TEXT is the string containing one or more lines.
+PREFIX is prepended to the beginning of each line.
+Returns the transformed string without modifying the buffer."
+ (let* ((lines (split-string text "\n"))
+ (has-trailing-newline (string-suffix-p "\n" text))
+ ;; If has trailing newline, last element will be empty string - exclude it
+ (lines-to-process (if (and has-trailing-newline
+ (not (null lines))
+ (string-empty-p (car (last lines))))
+ (butlast lines)
+ lines)))
+ (concat
+ (mapconcat (lambda (line) (concat prefix line)) lines-to-process "\n")
+ (if has-trailing-newline "\n" ""))))
(defun cj/prepend-to-lines-in-region-or-buffer (str)
"Prepend STR to the beginning of each line in the region or entire buffer."
(interactive "sEnter string to prepend: ")
- (let ((start-pos (if (use-region-p)
- (region-beginning)
- (point-min)))
- (end-pos (if (use-region-p)
- (region-end)
- (point-max))))
- (save-excursion
- (goto-char start-pos)
- (while (< (point) end-pos)
- (beginning-of-line 1)
- (insert str)
- (forward-line 1)))))
-
-;; Surround, append, prepend prefix keymap
+ (let* ((start-pos (if (use-region-p)
+ (region-beginning)
+ (point-min)))
+ (end-pos (if (use-region-p)
+ (region-end)
+ (point-max)))
+ (text (buffer-substring start-pos end-pos))
+ (insertion (cj/--prepend-to-lines text str)))
+ (delete-region start-pos end-pos)
+ (goto-char start-pos)
+ (insert insertion)))
+
+(defun cj/--indent-lines (text count use-tabs)
+ "Internal implementation: Indent each line in TEXT by COUNT characters.
+TEXT is the string containing one or more lines.
+COUNT is the number of indentation characters to add.
+USE-TABS when non-nil uses tabs instead of spaces for indentation.
+Returns the indented text without modifying the buffer."
+ (let ((indent-string (if use-tabs
+ (make-string count ?\t)
+ (make-string count ?\s))))
+ (cj/--prepend-to-lines text indent-string)))
+
+(defun cj/indent-lines-in-region-or-buffer (count use-tabs)
+ "Indent each line in region or buffer by COUNT characters.
+COUNT is the number of characters to indent (default 4).
+USE-TABS when non-nil (prefix argument) uses tabs instead of spaces."
+ (interactive "p\nP")
+ (let* ((start-pos (if (use-region-p)
+ (region-beginning)
+ (point-min)))
+ (end-pos (if (use-region-p)
+ (region-end)
+ (point-max)))
+ (text (buffer-substring start-pos end-pos))
+ (insertion (cj/--indent-lines text count use-tabs)))
+ (delete-region start-pos end-pos)
+ (goto-char start-pos)
+ (insert insertion)))
+
+(defun cj/--dedent-lines (text count)
+ "Internal implementation: Remove up to COUNT leading characters from each line.
+TEXT is the string containing one or more lines.
+COUNT is the maximum number of leading whitespace characters to remove.
+Removes spaces and tabs, but only up to COUNT characters per line.
+Returns the dedented text without modifying the buffer."
+ (let* ((lines (split-string text "\n"))
+ (has-trailing-newline (string-suffix-p "\n" text))
+ (lines-to-process (if (and has-trailing-newline
+ (not (null lines))
+ (string-empty-p (car (last lines))))
+ (butlast lines)
+ lines))
+ (dedented-lines
+ (mapcar
+ (lambda (line)
+ (let ((removed 0)
+ (pos 0)
+ (len (length line)))
+ (while (and (< removed count)
+ (< pos len)
+ (memq (aref line pos) '(?\s ?\t)))
+ (setq removed (1+ removed))
+ (setq pos (1+ pos)))
+ (substring line pos)))
+ lines-to-process)))
+ (concat
+ (mapconcat #'identity dedented-lines "\n")
+ (if has-trailing-newline "\n" ""))))
+
+(defun cj/dedent-lines-in-region-or-buffer (count)
+ "Remove up to COUNT leading whitespace characters from each line.
+COUNT is the number of characters to remove (default 4).
+Works on region if active, otherwise entire buffer."
+ (interactive "p")
+ (let* ((start-pos (if (use-region-p)
+ (region-beginning)
+ (point-min)))
+ (end-pos (if (use-region-p)
+ (region-end)
+ (point-max)))
+ (text (buffer-substring start-pos end-pos))
+ (insertion (cj/--dedent-lines text count)))
+ (delete-region start-pos end-pos)
+ (goto-char start-pos)
+ (insert insertion)))
+
+;; Text enclosure keymap
(defvar-keymap cj/enclose-map
- :doc "Keymap for enclosing text: surrounding, appending, and prepending"
+ :doc "Keymap for text enclosure: wrapping, line manipulation, and indentation"
"s" #'cj/surround-word-or-region
+ "w" #'cj/wrap-word-or-region
+ "u" #'cj/unwrap-word-or-region
"a" #'cj/append-to-lines-in-region-or-buffer
- "p" #'cj/prepend-to-lines-in-region-or-buffer)
+ "p" #'cj/prepend-to-lines-in-region-or-buffer
+ "i" #'cj/indent-lines-in-region-or-buffer
+ "d" #'cj/dedent-lines-in-region-or-buffer)
(keymap-set cj/custom-keymap "s" cj/enclose-map)
(with-eval-after-load 'which-key
diff --git a/modules/custom-whitespace.el b/modules/custom-whitespace.el
index a69d6138..df93459a 100644
--- a/modules/custom-whitespace.el
+++ b/modules/custom-whitespace.el
@@ -17,14 +17,32 @@
;;; Code:
+(eval-when-compile (defvar cj/custom-keymap)) ;; cj/custom-keymap defined in keybindings.el
;;; ---------------------- Whitespace Operations And Keymap ---------------------
+;; ------------------- Remove Leading/Trailing Whitespace ---------------------
+
+(defun cj/--remove-leading-trailing-whitespace (start end)
+ "Internal implementation: Remove leading and trailing whitespace.
+START and END define the region to operate on.
+Removes leading whitespace (^[ \\t]+) and trailing whitespace ([ \\t]+$).
+Preserves interior whitespace."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (save-excursion
+ (save-restriction
+ (narrow-to-region start end)
+ (goto-char (point-min))
+ (while (re-search-forward "^[ \t]+" nil t) (replace-match ""))
+ (goto-char (point-min))
+ (while (re-search-forward "[ \t]+$" nil t) (replace-match "")))))
+
(defun cj/remove-leading-trailing-whitespace ()
"Remove leading and trailing whitespace in a region, line, or buffer.
When called interactively:
- If a region is active, operate on the region.
-- If called with a \[universal-argument] prefix, operate on the entire buffer.
+- If called with a \\[universal-argument] prefix, operate on the entire buffer.
- Otherwise, operate on the current line."
(interactive)
(let ((start (cond (current-prefix-arg (point-min))
@@ -33,36 +51,90 @@ When called interactively:
(end (cond (current-prefix-arg (point-max))
((use-region-p) (region-end))
(t (line-end-position)))))
- (save-excursion
- (save-restriction
- (narrow-to-region start end)
- (goto-char (point-min))
- (while (re-search-forward "^[ \t]+" nil t) (replace-match ""))
- (goto-char (point-min))
- (while (re-search-forward "[ \t]+$" nil t) (replace-match ""))))))
+ (cj/--remove-leading-trailing-whitespace start end)))
+
+;; ----------------------- Collapse Whitespace ---------------------------------
+
+(defun cj/--collapse-whitespace (start end)
+ "Internal implementation: Collapse whitespace to single spaces.
+START and END define the region to operate on.
+Converts tabs to spaces, removes leading/trailing whitespace,
+and collapses multiple spaces to single space.
+Preserves newlines and line structure."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (save-excursion
+ (save-restriction
+ (narrow-to-region start end)
+ ;; Replace all tabs with space
+ (goto-char (point-min))
+ (while (search-forward "\t" nil t)
+ (replace-match " " nil t))
+ ;; Remove leading and trailing spaces (but not newlines)
+ (goto-char (point-min))
+ (while (re-search-forward "^[ \t]+\\|[ \t]+$" nil t)
+ (replace-match "" nil nil))
+ ;; Ensure only one space between words (but preserve newlines)
+ (goto-char (point-min))
+ (while (re-search-forward "[ \t]\\{2,\\}" nil t)
+ (replace-match " " nil nil)))))
(defun cj/collapse-whitespace-line-or-region ()
"Collapse whitespace to one space in the current line or active region.
-Ensure there is exactly one space between words and remove leading and trailing whitespace."
+Ensure there is exactly one space between words and remove leading and
+trailing whitespace."
(interactive)
+ (let* ((region-active (use-region-p))
+ (beg (if region-active (region-beginning) (line-beginning-position)))
+ (end (if region-active (region-end) (line-end-position))))
+ (cj/--collapse-whitespace beg end)))
+
+;; --------------------- Ensure Single Blank Line ------------------------------
+
+(defun cj/--ensure-single-blank-line (start end)
+ "Internal implementation: Collapse consecutive blank lines to one.
+START and END define the region to operate on.
+Replaces runs of 2+ blank lines with exactly one blank line.
+A blank line is defined as a line containing only whitespace."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
(save-excursion
- (let* ((region-active (use-region-p))
- (beg (if region-active (region-beginning) (line-beginning-position)))
- (end (if region-active (region-end) (line-end-position))))
- (save-restriction
- (narrow-to-region beg end)
- ;; Replace all tabs with space
- (goto-char (point-min))
- (while (search-forward "\t" nil t)
- (replace-match " " nil t))
- ;; Remove leading and trailing spaces
- (goto-char (point-min))
- (while (re-search-forward "^\\s-+\\|\\s-+$" nil t)
- (replace-match "" nil nil))
- ;; Ensure only one space between words/symbols
- (goto-char (point-min))
- (while (re-search-forward "\\s-\\{2,\\}" nil t)
- (replace-match " " nil nil))))))
+ (save-restriction
+ (narrow-to-region start end)
+ (goto-char (point-min))
+ ;; Match 2+ consecutive blank lines (lines with only whitespace)
+ ;; Pattern: Match sequences of blank lines (newline + optional space + newline)
+ ;; but preserve leading whitespace on the following content line
+ ;; Match: newline, then 1+ (optional whitespace + newline), capturing the last one
+ (while (re-search-forward "\n\\(?:[[:space:]]*\n\\)+" nil t)
+ (replace-match "\n\n")))))
+
+(defun cj/ensure-single-blank-line (start end)
+ "Collapse consecutive blank lines to exactly one blank line.
+START and END define the region to operate on.
+Operates on the active region when one exists.
+Prompt before operating on the whole buffer when no region is selected."
+ (interactive
+ (if (use-region-p)
+ (list (region-beginning) (region-end))
+ (if (yes-or-no-p "Ensure single blank lines in entire buffer? ")
+ (list (point-min) (point-max))
+ (user-error "Aborted"))))
+ (cj/--ensure-single-blank-line start end))
+
+;; ------------------------ Delete Blank Lines ---------------------------------
+
+(defun cj/--delete-blank-lines (start end)
+ "Internal implementation: Delete blank lines between START and END.
+Blank lines are lines containing only whitespace or nothing.
+Uses the regexp ^[[:space:]]*$ to match blank lines."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (save-excursion
+ (save-restriction
+ (widen)
+ ;; Regexp "^[[:space:]]*$" matches lines of zero or more spaces/tabs/newlines.
+ (flush-lines "^[[:space:]]*$" start end))))
(defun cj/delete-blank-lines-region-or-buffer (start end)
"Delete blank lines between START and END.
@@ -73,32 +145,62 @@ Signal a user error and do nothing when the user declines.
Restore point to its original position after deletion."
(interactive
(if (use-region-p)
- ;; grab its boundaries if there's a region
- (list (region-beginning) (region-end))
- ;; or ask if user intended operating on whole buffer
- (if (yes-or-no-p "Delete blank lines in entire buffer? ")
- (list (point-min) (point-max))
- (user-error "Aborted"))))
- (save-excursion
- (save-restriction
- (widen)
- ;; Regexp "^[[:space:]]*$" matches lines of zero or more spaces/tabs.
- (flush-lines "^[[:space:]]*$" start end)))
+ ;; grab its boundaries if there's a region
+ (list (region-beginning) (region-end))
+ ;; or ask if user intended operating on whole buffer
+ (if (yes-or-no-p "Delete blank lines in entire buffer? ")
+ (list (point-min) (point-max))
+ (user-error "Aborted"))))
+ (cj/--delete-blank-lines start end)
;; Return nil (Emacs conventions). Point is already restored.
nil)
+;; ------------------------- Delete All Whitespace -----------------------------
+
+(defun cj/--delete-all-whitespace (start end)
+ "Internal implementation: Delete all whitespace from region.
+START and END define the region to operate on.
+Removes all spaces, tabs, newlines, and carriage returns."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (save-excursion
+ (save-restriction
+ (narrow-to-region start end)
+ (goto-char (point-min))
+ (while (re-search-forward "[ \t\n\r]+" nil t)
+ (replace-match "")))))
+
+(defun cj/delete-all-whitespace (start end)
+ "Delete all whitespace between START and END.
+Removes all spaces, tabs, newlines, and carriage returns.
+Operates on the active region."
+ (interactive "*r")
+ (if (use-region-p)
+ (cj/--delete-all-whitespace start end)
+ (message "No region; nothing to delete.")))
+
+;; ------------------------- Hyphenate Whitespace ------------------------------
+
+(defun cj/--hyphenate-whitespace (start end)
+ "Internal implementation: Replace whitespace runs with hyphens.
+START and END define the region to operate on.
+Replaces all runs of spaces, tabs, newlines, and carriage returns with hyphens."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (save-excursion
+ (save-restriction
+ (narrow-to-region start end)
+ (goto-char (point-min))
+ (while (re-search-forward "[ \t\n\r]+" nil t)
+ (replace-match "-")))))
+
(defun cj/hyphenate-whitespace-in-region (start end)
"Replace runs of whitespace between START and END with hyphens.
Operate on the active region designated by START and END."
(interactive "*r")
(if (use-region-p)
- (save-excursion
- (save-restriction
- (narrow-to-region start end)
- (goto-char (point-min))
- (while (re-search-forward "[ \t\n\r]+" nil t)
- (replace-match "-"))))
- (message "No region; nothing to hyphenate.")))
+ (cj/--hyphenate-whitespace start end)
+ (message "No region; nothing to hyphenate.")))
;; Whitespace operations prefix and keymap
@@ -107,7 +209,11 @@ Operate on the active region designated by START and END."
"r" #'cj/remove-leading-trailing-whitespace
"c" #'cj/collapse-whitespace-line-or-region
"l" #'cj/delete-blank-lines-region-or-buffer
- "-" #'cj/hyphenate-whitespace-in-region)
+ "1" #'cj/ensure-single-blank-line
+ "d" #'cj/delete-all-whitespace
+ "-" #'cj/hyphenate-whitespace-in-region
+ "t" #'untabify
+ "T" #'tabify)
(keymap-set cj/custom-keymap "w" cj/whitespace-map)
(with-eval-after-load 'which-key
diff --git a/modules/flyspell-and-abbrev.el b/modules/flyspell-and-abbrev.el
index 12e0d348..379fc7b2 100644
--- a/modules/flyspell-and-abbrev.el
+++ b/modules/flyspell-and-abbrev.el
@@ -111,7 +111,6 @@
;; ------------------------------ Flyspell Toggle ------------------------------
;; easy toggling flyspell and also leverage the 'for-buffer-type' functionality.
-;;;###autoload
(defun cj/flyspell-toggle ()
"Turn Flyspell on if it is off, or off if it is on.
@@ -198,7 +197,6 @@ buffer."
(downcase misspelled-word)
nil)))
-;;;###autoload
(defun cj/flyspell-then-abbrev (p)
"Find and correct the previous misspelled word, creating an abbrev.
diff --git a/modules/font-config.el b/modules/font-config.el
index 1541f55f..eea09da6 100644
--- a/modules/font-config.el
+++ b/modules/font-config.el
@@ -142,7 +142,6 @@ If FRAME is nil, uses the selected frame."
;; ----------------------------- Font Install Check ----------------------------
;; convenience function to indicate whether a font is available by name.
-;;;###autoload
(defun cj/font-installed-p (font-name)
"Check if font with FONT-NAME is available."
(if (find-font (font-spec :name font-name))
@@ -224,7 +223,6 @@ If FRAME is nil, uses the selected frame."
;; -------------------------- Display Available Fonts --------------------------
;; display all available fonts on the system in a side panel
-;;;###autoload
(defun cj/display-available-fonts ()
"Display a list of all font faces with sample text in another read-only buffer."
(interactive)
diff --git a/modules/jumper.el b/modules/jumper.el
index e1025472..67d930aa 100644
--- a/modules/jumper.el
+++ b/modules/jumper.el
@@ -10,24 +10,76 @@
;; Jumper provides a simple way to store and jump between locations
;; in your codebase without needing to remember register assignments.
+;;
+;; PURPOSE:
+;;
+;; When working on large codebases, you often need to jump between
+;; multiple related locations: a function definition, its tests, its
+;; callers, configuration files, etc. Emacs registers are perfect for
+;; this, but require you to remember which register you assigned to
+;; which location. Jumper automates register management, letting you
+;; focus on your work instead of bookkeeping.
+;;
+;; WORKFLOW:
+;;
+;; 1. Navigate to an important location in your code
+;; 2. Press M-SPC SPC to store it (automatically assigned to register 0)
+;; 3. Continue working, storing more locations as needed (registers 1-9)
+;; 4. Press M-SPC j to jump back to any stored location
+;; 5. Select from the list using completion (shows file, line, context)
+;; 6. Press M-SPC d to remove locations you no longer need
+;;
+;; RECOMMENDED USAGE:
+;;
+;; Store locations temporarily while working on a feature:
+;; - Store the main function you're implementing
+;; - Store the test file where you're writing tests
+;; - Store the caller that needs updating
+;; - Store the documentation that needs changes
+;; - Jump between them freely as you work
+;; - Clear them when done with the feature
+;;
+;; SPECIAL BEHAVIORS:
+;;
+;; - Duplicate prevention: Storing the same location twice shows a message
+;; instead of wasting a register slot.
+;;
+;; - Single location toggle: When only one location is stored, M-SPC j
+;; toggles between that location and your current position. Perfect for
+;; rapid back-and-forth between two related files.
+;;
+;; - Last location tracking: The last position before each jump is saved
+;; in register 'z', allowing quick "undo" of navigation.
+;;
+;; - Smart selection: With multiple locations, completing-read shows
+;; helpful context: "[0] filename.el:42 - function definition..."
+;;
+;; KEYBINDINGS:
+;;
+;; M-SPC SPC Store current location in next available register
+;; M-SPC j Jump to a stored location (with completion)
+;; M-SPC d Delete a stored location from the list
+;;
+;; CONFIGURATION:
+;;
+;; You can customize the prefix key and maximum locations:
+;;
+;; (setq jumper-prefix-key "C-c j") ; Change prefix key
+;; (setq jumper-max-locations 20) ; Store up to 20 locations
+;;
+;; Note: Changing jumper-max-locations requires restarting Emacs or
+;; manually reinitializing jumper--registers.
;;; Code:
-(defgroup jumper nil
- "Quick navigation between stored locations."
- :group 'convenience)
+(require 'cl-lib)
-(defcustom jumper-prefix-key "M-SPC"
+(defvar jumper-prefix-key "M-SPC"
"Prefix key for jumper commands.
+Note that using M-SPC will override the default binding to just-one-space.")
-Note that using M-SPC will override the default binding to just-one-space."
- :type 'string
- :group 'jumper)
-
-(defcustom jumper-max-locations 10
- "Maximum number of locations to store."
- :type 'integer
- :group 'jumper)
+(defvar jumper-max-locations 10
+ "Maximum number of locations to store.")
;; Internal variables
(defvar jumper--registers (make-vector jumper-max-locations nil)
@@ -50,12 +102,10 @@ Note that using M-SPC will override the default binding to just-one-space."
"Check if current location is already stored."
(let ((key (jumper--location-key))
(found nil))
- (dotimes (i
- jumper--next-index found)
+ (dotimes (i jumper--next-index found)
(let* ((reg (aref jumper--registers i))
- (pos (get-register reg))
- (marker (and pos (registerv-data pos))))
- (when marker
+ (marker (get-register reg)))
+ (when (and marker (markerp marker))
(save-current-buffer
(set-buffer (marker-buffer marker))
(save-excursion
@@ -70,9 +120,8 @@ Note that using M-SPC will override the default binding to just-one-space."
(defun jumper--format-location (index)
"Format location at INDEX for display."
(let* ((reg (aref jumper--registers index))
- (pos (get-register reg))
- (marker (and pos (registerv-data pos))))
- (when marker
+ (marker (get-register reg)))
+ (when (and marker (markerp marker))
(save-current-buffer
(set-buffer (marker-buffer marker))
(save-excursion
@@ -86,49 +135,83 @@ Note that using M-SPC will override the default binding to just-one-space."
(min (+ (line-beginning-position) 40)
(line-end-position)))))))))
+(defun jumper--do-store-location ()
+ "Store current location in the next free register.
+Returns: \\='already-exists if location is already stored,
+ \\='no-space if all registers are full,
+ register character if successfully stored."
+ (cond
+ ((jumper--location-exists-p) 'already-exists)
+ ((not (jumper--register-available-p)) 'no-space)
+ (t
+ (let ((reg (+ ?0 jumper--next-index)))
+ (point-to-register reg)
+ (aset jumper--registers jumper--next-index reg)
+ (setq jumper--next-index (1+ jumper--next-index))
+ reg))))
+
(defun jumper-store-location ()
"Store current location in the next free register."
(interactive)
- (if (jumper--location-exists-p)
- (message "Location already stored")
- (if (jumper--register-available-p)
- (let ((reg (+ ?0 jumper--next-index)))
- (point-to-register reg)
- (aset jumper--registers jumper--next-index reg)
- (setq jumper--next-index (1+ jumper--next-index))
- (message "Location stored in register %c" reg))
- (message "Sorry - all jump locations are filled!"))))
+ (pcase (jumper--do-store-location)
+ ('already-exists (message "Location already stored"))
+ ('no-space (message "Sorry - all jump locations are filled!"))
+ (reg (message "Location stored in register %c" reg))))
+
+(defun jumper--do-jump-to-location (target-idx)
+ "Jump to location at TARGET-IDX.
+TARGET-IDX: -1 for last location, 0-9 for stored locations, nil for toggle.
+Returns: \\='no-locations if no locations stored,
+ \\='already-there if at the only location (toggle case),
+ \\='jumped if successfully jumped."
+ (cond
+ ((= jumper--next-index 0) 'no-locations)
+ ;; Toggle behavior when target-idx is nil and only 1 location
+ ((and (null target-idx) (= jumper--next-index 1))
+ (if (jumper--location-exists-p)
+ 'already-there
+ (let ((reg (aref jumper--registers 0)))
+ (point-to-register jumper--last-location-register)
+ (jump-to-register reg)
+ 'jumped)))
+ ;; Jump to specific target
+ (t
+ (if (= target-idx -1)
+ ;; Jumping to last location - don't overwrite it
+ (jump-to-register jumper--last-location-register)
+ ;; Jumping to stored location - save current for "last"
+ (progn
+ (point-to-register jumper--last-location-register)
+ (jump-to-register (aref jumper--registers target-idx))))
+ 'jumped)))
(defun jumper-jump-to-location ()
"Jump to a stored location."
(interactive)
- (if (= jumper--next-index 0)
- (message "No locations stored")
- (if (= jumper--next-index 1)
- ;; Special case for one location - toggle behavior
- (let ((reg (aref jumper--registers 0)))
- (if (jumper--location-exists-p)
- (message "You're already at the stored location")
- (point-to-register jumper--last-location-register)
- (jump-to-register reg)
- (message "Jumped to location")))
- ;; Multiple locations - use completing-read
- (let* ((locations
- (cl-loop for i from 0 below jumper--next-index
- for fmt = (jumper--format-location i)
- when fmt collect (cons fmt i)))
- ;; Add last location if available
- (last-pos (get-register jumper--last-location-register))
- (locations (if last-pos
- (cons (cons "[z] Last location" -1) locations)
- locations))
- (choice (completing-read "Jump to: " locations nil t))
- (idx (cdr (assoc choice locations))))
- (point-to-register jumper--last-location-register)
- (if (= idx -1)
- (jump-to-register jumper--last-location-register)
- (jump-to-register (aref jumper--registers idx)))
- (message "Jumped to location")))))
+ (cond
+ ;; No locations
+ ((= jumper--next-index 0)
+ (message "No locations stored"))
+ ;; Single location - toggle
+ ((= jumper--next-index 1)
+ (pcase (jumper--do-jump-to-location nil)
+ ('already-there (message "You're already at the stored location"))
+ ('jumped (message "Jumped to location"))))
+ ;; Multiple locations - prompt user
+ (t
+ (let* ((locations
+ (cl-loop for i from 0 below jumper--next-index
+ for fmt = (jumper--format-location i)
+ when fmt collect (cons fmt i)))
+ ;; Add last location if available
+ (last-pos (get-register jumper--last-location-register))
+ (locations (if last-pos
+ (cons (cons "[z] Last location" -1) locations)
+ locations))
+ (choice (completing-read "Jump to: " locations nil t))
+ (idx (cdr (assoc choice locations))))
+ (jumper--do-jump-to-location idx)
+ (message "Jumped to location")))))
(defun jumper--reorder-registers (removed-idx)
"Reorder registers after removing the one at REMOVED-IDX."
@@ -139,32 +222,40 @@ Note that using M-SPC will override the default binding to just-one-space."
(aset jumper--registers i next-reg))))
(setq jumper--next-index (1- jumper--next-index)))
+(defun jumper--do-remove-location (index)
+ "Remove location at INDEX.
+Returns: \\='no-locations if no locations stored,
+ \\='cancelled if index is -1,
+ t if successfully removed."
+ (cond
+ ((= jumper--next-index 0) 'no-locations)
+ ((= index -1) 'cancelled)
+ (t
+ (jumper--reorder-registers index)
+ t)))
+
(defun jumper-remove-location ()
"Remove a stored location."
(interactive)
(if (= jumper--next-index 0)
- (message "No locations stored")
- (let* ((locations
- (cl-loop for i from 0 below jumper--next-index
- for fmt = (jumper--format-location i)
- when fmt collect (cons fmt i)))
- (locations (cons (cons "Cancel" -1) locations))
- (choice (completing-read "Remove location: " locations nil t))
- (idx (cdr (assoc choice locations))))
- (if (= idx -1)
- (message "Operation cancelled")
- (jumper--reorder-registers idx)
- (message "Location removed")))))
-
-(defvar jumper-map
- (let ((map (make-sparse-keymap)))
- (define-key map (kbd "SPC") #'jumper-store-location)
- (define-key map (kbd "j") #'jumper-jump-to-location)
- (define-key map (kbd "d") #'jumper-remove-location)
- map)
- "Keymap for jumper commands.")
-
-;;;###autoload
+ (message "No locations stored")
+ (let* ((locations
+ (cl-loop for i from 0 below jumper--next-index
+ for fmt = (jumper--format-location i)
+ when fmt collect (cons fmt i)))
+ (locations (cons (cons "Cancel" -1) locations))
+ (choice (completing-read "Remove location: " locations nil t))
+ (idx (cdr (assoc choice locations))))
+ (pcase (jumper--do-remove-location idx)
+ ('cancelled (message "Operation cancelled"))
+ ('t (message "Location removed"))))))
+
+(defvar-keymap jumper-map
+ :doc "Keymap for jumper commands"
+ "SPC" #'jumper-store-location
+ "j" #'jumper-jump-to-location
+ "d" #'jumper-remove-location)
+
(defun jumper-setup-keys ()
"Setup default keybindings for jumper."
(interactive)
@@ -173,5 +264,13 @@ Note that using M-SPC will override the default binding to just-one-space."
;; Call jumper-setup-keys when the package is loaded
(jumper-setup-keys)
+;; which-key integration
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "M-SPC" "jumper menu"
+ "M-SPC SPC" "store location"
+ "M-SPC j" "jump to location"
+ "M-SPC d" "remove location"))
+
(provide 'jumper)
;;; jumper.el ends here.
diff --git a/modules/lipsum-generator.el b/modules/lipsum-generator.el
index b328b989..11ed8caa 100644
--- a/modules/lipsum-generator.el
+++ b/modules/lipsum-generator.el
@@ -129,25 +129,21 @@ Defaults to 'liber-primus.txt' in the modules directory."
(when candidates
(nth (random (length candidates)) candidates))))
-;;;###autoload
(defvar cj/lipsum-chain (cj/markov-chain-create)
"Global Markov chain for lipsum generation.")
-;;;###autoload
(defun cj/lipsum-reset ()
"Reset the global lipsum Markov chain."
(interactive)
(setq cj/lipsum-chain (cj/markov-chain-create))
(message "cj/lipsum-chain reset."))
-;;;###autoload
(defun cj/lipsum-learn-region (beg end)
"Learn text from region."
(interactive "r")
(cj/markov-learn cj/lipsum-chain (buffer-substring-no-properties beg end))
(message "Learned from region."))
-;;;###autoload
(defun cj/lipsum-learn-buffer ()
"Learn from entire buffer."
(interactive)
@@ -155,7 +151,6 @@ Defaults to 'liber-primus.txt' in the modules directory."
(buffer-substring-no-properties (point-min) (point-max)))
(message "Learned from buffer."))
-;;;###autoload
(defun cj/lipsum-learn-file (file)
"Learn from FILE containing plain text."
(interactive "fTrain from file: ")
@@ -164,12 +159,10 @@ Defaults to 'liber-primus.txt' in the modules directory."
(cj/markov-learn cj/lipsum-chain (buffer-string)))
(message "Learned from file: %s" file))
-;;;###autoload
(defun cj/lipsum (n)
"Return N words of lorem ipsum."
(cj/markov-generate cj/lipsum-chain n '("Lorem" "ipsum")))
-;;;###autoload
(defun cj/lipsum-insert (n)
"Insert N words of lorem ipsum at point."
(interactive "nNumber of words: ")
@@ -181,7 +174,6 @@ Defaults to 'liber-primus.txt' in the modules directory."
(defconst cj/lipsum-title-max 8)
(defconst cj/lipsum-title-small 3)
-;;;###autoload
(defun cj/lipsum-title ()
"Generate a pseudo-Latin title."
(interactive)
@@ -205,7 +197,6 @@ Defaults to 'liber-primus.txt' in the modules directory."
;;; Paragraphs
-;;;###autoload
(defun cj/lipsum-paragraphs (count &optional min max)
"Insert COUNT paragraphs of lipsum.
Each paragraph has a random length between MIN and MAX words.
diff --git a/modules/lorem-generator.el b/modules/lorem-optimum.el
index 6148dfdc..6ccca55f 100644
--- a/modules/lorem-generator.el
+++ b/modules/lorem-optimum.el
@@ -1,4 +1,4 @@
-;;; lorem-generator.el --- Fake Latin Text Generator -*- coding: utf-8; lexical-binding: t; -*-
+;;; lorem-optimum.el --- Fake Latin Text Generator -*- coding: utf-8; lexical-binding: t; -*-
;;
;; Author: Craig Jennings
;; Version: 0.5
@@ -24,6 +24,19 @@
(require 'cl-lib)
+;;; Configuration
+
+(defvar cj/lipsum-training-file "assets/liber-primus.txt"
+ "Default training file name (relative to `user-emacs-directory`).")
+
+(defvar cj/lipsum-default-file
+ (expand-file-name cj/lipsum-training-file user-emacs-directory)
+ "Default training file for cj-lipsum.
+
+This should be a plain UTF-8 text file with hundreds of Latin words
+or sentences. By default it points to the file specified in
+`cj/lipsum-training-file` relative to `user-emacs-directory`.")
+
(cl-defstruct (cj/markov-chain
(:constructor cj/markov-chain-create))
"An order-two Markov chain."
@@ -31,25 +44,45 @@
(keys nil))
(defun cj/markov-tokenize (text)
- "Split TEXT into tokens: words and punctuation separately."
- (let ((case-fold-search nil))
- (split-string text "\\b" t "[[:space:]\n]+")))
-
+ "Split TEXT into tokens: words and punctuation separately.
+Returns a list of words and punctuation marks as separate tokens."
+ (let ((tokens '())
+ (pos 0)
+ (len (length text)))
+ (while (< pos len)
+ (cond
+ ;; Skip whitespace
+ ((string-match-p "[[:space:]]" (substring text pos (1+ pos)))
+ (setq pos (1+ pos)))
+ ;; Match word (sequence of alphanumeric characters)
+ ((string-match "\\`\\([[:alnum:]]+\\)" (substring text pos))
+ (let ((word (match-string 1 (substring text pos))))
+ (push word tokens)
+ (setq pos (+ pos (length word)))))
+ ;; Match punctuation (single character)
+ ((string-match "\\`\\([[:punct:]]\\)" (substring text pos))
+ (let ((punct (match-string 1 (substring text pos))))
+ (push punct tokens)
+ (setq pos (+ pos (length punct)))))
+ ;; Skip any other character
+ (t (setq pos (1+ pos)))))
+ (nreverse tokens)))
(defun cj/markov-learn (chain text)
"Add TEXT into the Markov CHAIN with tokenized input."
- (let* ((words (cj/markov-tokenize text))
+ (let* ((word-list (cj/markov-tokenize text))
+ ;; Convert to vector for O(1) access instead of O(n) with nth
+ (words (vconcat word-list))
(len (length words)))
(cl-loop for i from 0 to (- len 3)
- for a = (nth i words)
- for b = (nth (1+ i) words)
- for c = (nth (+ i 2) words)
+ for a = (aref words i)
+ for b = (aref words (1+ i))
+ for c = (aref words (+ i 2))
do (let* ((bigram (list a b))
(nexts (gethash bigram (cj/markov-chain-map chain))))
(puthash bigram (cons c nexts)
(cj/markov-chain-map chain)))))
- (setf (cj/markov-chain-keys chain)
- (cl-loop for k being the hash-keys of (cj/markov-chain-map chain)
- collect k)))
+ ;; Invalidate cached keys after learning new data
+ (setf (cj/markov-chain-keys chain) nil))
(defun cj/markov-fix-capitalization (sentence)
"Capitalize the first word and the first word after .!? in SENTENCE."
@@ -94,7 +127,7 @@
(defun cj/markov-generate (chain n &optional start)
"Generate a sentence of N tokens from CHAIN."
- (when (cj/markov-chain-keys chain)
+ (when (> (hash-table-count (cj/markov-chain-map chain)) 0)
(let* ((state (or (and start
(gethash start (cj/markov-chain-map chain))
start)
@@ -116,33 +149,37 @@
(cj/markov-join-tokens tokens))))
(defun cj/markov-random-key (chain)
- (nth (random (length (cj/markov-chain-keys chain)))
- (cj/markov-chain-keys chain)))
+ "Return a random bigram key from CHAIN.
+Builds and caches the keys list lazily if not already cached."
+ (unless (cj/markov-chain-keys chain)
+ ;; Lazily build keys list only when needed
+ (setf (cj/markov-chain-keys chain)
+ (cl-loop for k being the hash-keys of (cj/markov-chain-map chain)
+ collect k)))
+ (let ((keys (cj/markov-chain-keys chain)))
+ (when keys
+ (nth (random (length keys)) keys))))
(defun cj/markov-next-word (chain bigram)
(let ((candidates (gethash bigram (cj/markov-chain-map chain))))
(when candidates
(nth (random (length candidates)) candidates))))
-;;;###autoload
(defvar cj/lipsum-chain (cj/markov-chain-create)
"Global Markov chain for lipsum generation.")
-;;;###autoload
(defun cj/lipsum-reset ()
"Reset the global lipsum Markov chain."
(interactive)
(setq cj/lipsum-chain (cj/markov-chain-create))
(message "cj/lipsum-chain reset."))
-;;;###autoload
(defun cj/lipsum-learn-region (beg end)
"Learn text from region."
(interactive "r")
(cj/markov-learn cj/lipsum-chain (buffer-substring-no-properties beg end))
(message "Learned from region."))
-;;;###autoload
(defun cj/lipsum-learn-buffer ()
"Learn from entire buffer."
(interactive)
@@ -150,7 +187,6 @@
(buffer-substring-no-properties (point-min) (point-max)))
(message "Learned from buffer."))
-;;;###autoload
(defun cj/lipsum-learn-file (file)
"Learn from FILE containing plain text."
(interactive "fTrain from file: ")
@@ -159,12 +195,10 @@
(cj/markov-learn cj/lipsum-chain (buffer-string)))
(message "Learned from file: %s" file))
-;;;###autoload
(defun cj/lipsum (n)
"Return N words of lorem ipsum."
(cj/markov-generate cj/lipsum-chain n '("Lorem" "ipsum")))
-;;;###autoload
(defun cj/lipsum-insert (n)
"Insert N words of lorem ipsum at point."
(interactive "nNumber of words: ")
@@ -176,7 +210,6 @@
(defconst cj/lipsum-title-max 8)
(defconst cj/lipsum-title-small 3)
-;;;###autoload
(defun cj/lipsum-title ()
"Generate a pseudo-Latin title."
(interactive)
@@ -190,6 +223,7 @@
(or (cj/markov-next-word cj/lipsum-chain state)
(cadr (cj/markov-random-key cj/lipsum-chain))))))
collect (replace-regexp-in-string "^[[:punct:]]+\\|[[:punct:]]+$" "" w))))
+ ;; Filter empty strings from generated words
(setq words (cl-remove-if #'string-empty-p words))
(mapconcat
(lambda (word idx)
@@ -200,7 +234,6 @@
;;; Paragraphs
-;;;###autoload
(defun cj/lipsum-paragraphs (count &optional min max)
"Insert COUNT paragraphs of lipsum.
@@ -213,23 +246,6 @@ Defaults: MIN=30, MAX=80."
(let ((len (+ min (random (1+ (- max min))))))
(insert (cj/lipsum len) "\n\n")))))
-;;; Customization
-
-(defgroup cj-lipsum nil
- "Pseudo-Latin lorem ipsum text generator."
- :prefix "cj/lipsum-"
- :group 'text)
-
-(defcustom cj/lipsum-default-file
- (expand-file-name "latin.txt"
- (file-name-directory (or load-file-name buffer-file-name)))
- "Default training file for cj-lipsum.
-
-This should be a plain UTF-8 text file with hundreds of Latin words
-or sentences. By default it points to the bundled `latin.txt`."
- :type 'file
- :group 'cj-lipsum)
-
;;; Initialization: train on default file
(defun cj/lipsum--init ()
"Initialize cj-lipsum by learning from `cj/lipsum-default-file`."
@@ -240,5 +256,5 @@ or sentences. By default it points to the bundled `latin.txt`."
(cj/lipsum--init)
-(provide 'lorem-generator)
-;;; lorem-generator.el ends here.
+(provide 'lorem-optimum)
+;;; lorem-optimum.el ends here.
diff --git a/modules/org-agenda-config.el b/modules/org-agenda-config.el
index c7aac99b..c86ac6a3 100644
--- a/modules/org-agenda-config.el
+++ b/modules/org-agenda-config.el
@@ -244,7 +244,6 @@ The agenda is rebuilt from all sources before display, including:
(defun cj/add-timestamp-to-org-entry (s)
"Add an event with time S to appear underneath the line-at-point.
-
This allows a line to show in an agenda without being scheduled or a deadline."
(interactive "sTime: ")
(defvar cj/timeformat "%Y-%m-%d %a")
@@ -253,7 +252,6 @@ This allows a line to show in an agenda without being scheduled or a deadline."
(open-line 1)
(forward-line 1)
(insert (concat "<" (format-time-string cj/timeformat (current-time)) " " s ">" ))))
-;;(global-set-key (kbd "M-t") #'cj/add-timestamp-to-org-entry)
;; --------------------------- Notifications / Alerts --------------------------
;; send libnotify notifications for agenda items
diff --git a/modules/org-contacts-config.el b/modules/org-contacts-config.el
index 706412a2..df4e18f1 100644
--- a/modules/org-contacts-config.el
+++ b/modules/org-contacts-config.el
@@ -20,17 +20,17 @@
;; Add a wrapper function that ensures proper context
(defun cj/org-contacts-anniversaries-safe ()
- "Safely call org-contacts-anniversaries with required bindings."
- (require 'diary-lib)
- ;; These need to be dynamically bound for diary functions
- (defvar date)
- (defvar entry)
- (defvar original-date)
- (let ((date (calendar-current-date))
- (entry "")
- (original-date (calendar-current-date)))
- (ignore-errors
- (org-contacts-anniversaries))))
+ "Safely call org-contacts-anniversaries with required bindings."
+ (require 'diary-lib)
+ ;; These need to be dynamically bound for diary functions
+ (defvar date)
+ (defvar entry)
+ (defvar original-date)
+ (let ((date (calendar-current-date))
+ (entry "")
+ (original-date (calendar-current-date)))
+ (ignore-errors
+ (org-contacts-anniversaries))))
;; Use the safe wrapper instead
(add-hook 'org-agenda-finalize-hook 'cj/org-contacts-anniversaries-safe))
@@ -39,8 +39,8 @@
(with-eval-after-load 'org-capture
(add-to-list 'org-capture-templates
- '("C" "Contact" entry (file+headline contacts-file "Contacts")
- "* %(cj/org-contacts-template-name)
+ '("C" "Contact" entry (file+headline contacts-file "Contacts")
+ "* %(cj/org-contacts-template-name)
:PROPERTIES:
:EMAIL: %(cj/org-contacts-template-email)
:PHONE: %^{Phone(s) - separate multiple with commas}
@@ -57,31 +57,31 @@ Added: %U")))
;; duplicate?!?
;; (with-eval-after-load 'org-capture
;; (add-to-list 'org-capture-templates
-;; '("C" "Contact" entry (file+headline contacts-file "Contacts")
-;; "* %(cj/org-contacts-template-name)
+;; '("C" "Contact" entry (file+headline contacts-file "Contacts")
+;; "* %(cj/org-contacts-template-name)
;; Added: %U")))
(defun cj/org-contacts-template-name ()
"Get name for contact template from context."
(let ((name (when (boundp 'cj/contact-name) cj/contact-name)))
- (or name
- (when (eq major-mode 'mu4e-headers-mode)
- (mu4e-message-field (mu4e-message-at-point) :from-or-to))
- (when (eq major-mode 'mu4e-view-mode)
- (mu4e-message-field mu4e~view-message :from-or-to))
- (read-string "Name: "))))
+ (or name
+ (when (eq major-mode 'mu4e-headers-mode)
+ (mu4e-message-field (mu4e-message-at-point) :from-or-to))
+ (when (eq major-mode 'mu4e-view-mode)
+ (mu4e-message-field mu4e~view-message :from-or-to))
+ (read-string "Name: "))))
(defun cj/org-contacts-template-email ()
"Get email for contact template from context."
(let ((email (when (boundp 'cj/contact-email) cj/contact-email)))
- (or email
- (when (eq major-mode 'mu4e-headers-mode)
- (let ((from (mu4e-message-field (mu4e-message-at-point) :from)))
- (when from (cdr (car from)))))
- (when (eq major-mode 'mu4e-view-mode)
- (let ((from (mu4e-message-field mu4e~view-message :from)))
- (when from (cdr (car from)))))
- (read-string "Email: "))))
+ (or email
+ (when (eq major-mode 'mu4e-headers-mode)
+ (let ((from (mu4e-message-field (mu4e-message-at-point) :from)))
+ (when from (cdr (car from)))))
+ (when (eq major-mode 'mu4e-view-mode)
+ (let ((from (mu4e-message-field mu4e~view-message :from)))
+ (when from (cdr (car from)))))
+ (read-string "Email: "))))
;;; ------------------------- Quick Contact Functions ---------------------------
@@ -91,13 +91,13 @@ Added: %U")))
(find-file contacts-file)
(goto-char (point-min))
(let ((contact (completing-read "Contact: "
- (org-map-entries
- (lambda () (nth 4 (org-heading-components)))
- nil (list contacts-file)))))
- (goto-char (point-min))
- (search-forward contact)
- (org-fold-show-entry)
- (org-reveal)))
+ (org-map-entries
+ (lambda () (nth 4 (org-heading-components)))
+ nil (list contacts-file)))))
+ (goto-char (point-min))
+ (search-forward contact)
+ (org-fold-show-entry)
+ (org-reveal)))
(defun cj/org-contacts-new ()
"Create a new contact."
@@ -110,19 +110,6 @@ Added: %U")))
(find-file contacts-file)
(org-columns))
-;;; -------------------------- Org-Roam Integration -----------------------------
-
-;; (with-eval-after-load 'org-roam
-;; (defun cj/org-contacts-link-to-roam ()
-;; "Link current contact to an org-roam node."
-;; (interactive)
-;; (when (eq major-mode 'org-mode)
-;; (let ((contact-name (org-entry-get (point) "ITEM")))
-;; (org-set-property "ROAM_REFS"
-;; (org-roam-node-id
-;; (org-roam-node-read nil nil nil nil
-;; :initial-input contact-name)))))))
-
;;; ----------------------------- Birthday Agenda --------------------------------
(with-eval-after-load 'org-agenda
@@ -131,40 +118,48 @@ Added: %U")))
;; Custom agenda command for upcoming birthdays
(add-to-list 'org-agenda-custom-commands
- '("b" "Birthdays and Anniversaries"
- ((tags-todo "BIRTHDAY|ANNIVERSARY"
- ((org-agenda-overriding-header "Upcoming Birthdays and Anniversaries")
- (org-agenda-sorting-strategy '(time-up))))))))
+ '("b" "Birthdays and Anniversaries"
+ ((tags-todo "BIRTHDAY|ANNIVERSARY"
+ ((org-agenda-overriding-header "Upcoming Birthdays and Anniversaries")
+ (org-agenda-sorting-strategy '(time-up))))))))
;;; ---------------------------- Core Contact Data Functions ---------------------------
(defun cj/org-contacts--props-matching (entry pattern)
"Return all property values from ENTRY whose keys match PATTERN (a regexp)."
(let ((props (nth 2 entry)))
- (delq nil
- (mapcar (lambda (prop)
- (when (string-match-p pattern (car prop))
- (cdr prop)))
- props))))
+ (delq nil
+ (mapcar (lambda (prop)
+ (when (string-match-p pattern (car prop))
+ (cdr prop)))
+ props))))
+
+(defun cj/--parse-email-string (name email-string)
+ "Parse EMAIL-STRING and return formatted entries for NAME.
+EMAIL-STRING may contain multiple emails separated by commas, semicolons, or spaces.
+Returns a list of strings formatted as 'Name <email>'.
+Returns nil if EMAIL-STRING is nil or contains only whitespace."
+ (when (and email-string (string-match-p "[^[:space:]]" email-string))
+ (let ((emails (split-string email-string "[,;[:space:]]+" t)))
+ (mapcar (lambda (email)
+ (format "%s <%s>" name (string-trim email)))
+ emails))))
(defun cj/get-all-contact-emails ()
"Retrieve all contact emails from org-contacts database.
Returns a list of formatted strings like \"Name <email@example.com>\".
This is the core function used by the mu4e integration module."
(let ((contacts (org-contacts-db)))
- (delq nil
- (mapcan (lambda (e)
- (let* ((name (car e))
- ;; This returns a LIST of email strings
- (email-strings (cj/org-contacts--props-matching e "EMAIL")))
- ;; Need mapcan here to handle the list
- (mapcan (lambda (email-str)
- (when (and email-str (string-match-p "[^[:space:]]" email-str))
- (mapcar (lambda (email)
- (format "%s <%s>" name (string-trim email)))
- (split-string email-str "[,;[:space:]]+" t))))
- email-strings)))
- contacts))))
+ (delq nil
+ (mapcan (lambda (e)
+ (let* ((name (car e))
+ ;; This returns a LIST of email strings
+ (email-strings (cj/org-contacts--props-matching e "EMAIL")))
+ ;; Process each email string using the extracted parser
+ (mapcan (lambda (email-str)
+ (cj/--parse-email-string name email-str))
+ email-strings)))
+ contacts))))
;; Simple insertion function for use outside of mu4e
(defun cj/insert-contact-email ()
@@ -173,8 +168,8 @@ For use outside of mu4e compose buffers. In mu4e, the integration
module provides more sophisticated completion."
(interactive)
(let* ((items (cj/get-all-contact-emails))
- (selected (completing-read "Contact: " items nil t)))
- (insert selected)))
+ (selected (completing-read "Contact: " items nil t)))
+ (insert selected)))
;;; -------------------------------- Org Contacts --------------------------------
@@ -195,9 +190,9 @@ module provides more sophisticated completion."
(setq mu4e-org-contacts-file contacts-file)
(add-to-list 'mu4e-headers-actions
- '("org-contact-add" . mu4e-action-add-org-contact) t)
+ '("org-contact-add" . mu4e-action-add-org-contact) t)
(add-to-list 'mu4e-view-actions
- '("org-contact-add" . mu4e-action-add-org-contact) t)
+ '("org-contact-add" . mu4e-action-add-org-contact) t)
;; Disable mu4e's built-in completion in favor of our custom solution
(setq mu4e-compose-complete-addresses nil))
@@ -207,11 +202,11 @@ module provides more sophisticated completion."
;; Keymap for `org-contacts' commands
(defvar cj/org-contacts-map
(let ((map (make-sparse-keymap)))
- (keymap-set map "f" #'cj/org-contacts-find) ;; find contact
- (keymap-set map "n" #'cj/org-contacts-new) ;; new contact
- (keymap-set map "e" #'cj/insert-contact-email) ;; inserts email from org-contact
- (keymap-set map "v" #'cj/org-contacts-view-all) ;; view all contacts
- map)
+ (keymap-set map "f" #'cj/org-contacts-find) ;; find contact
+ (keymap-set map "n" #'cj/org-contacts-new) ;; new contact
+ (keymap-set map "e" #'cj/insert-contact-email) ;; inserts email from org-contact
+ (keymap-set map "v" #'cj/org-contacts-view-all) ;; view all contacts
+ map)
"Keymap for `org-contacts' commands.")
;; Bind the org-contacts map to the C-c C prefix
diff --git a/modules/org-roam-config.el b/modules/org-roam-config.el
index 18552b1d..07098743 100644
--- a/modules/org-roam-config.el
+++ b/modules/org-roam-config.el
@@ -1,7 +1,15 @@
;;; org-roam-config.el --- Org-Roam Config -*- lexical-binding: t; coding: utf-8; -*-
;; author: Craig Jennings <c@cjennings.net>
;;; Commentary:
-;; Currently a work in progress. The initial version of this was taken from David Wilson:
+;; Configuration and utilities for org-roam knowledge management.
+;;
+;; Key features:
+;; - Custom capture templates for different node types (v2mom, recipe, topic)
+;; - Automatic moving of completed tasks to daily journal
+;; - Tag-based node filtering and finding
+;; - Branch extraction to new roam nodes (cj/move-org-branch-to-roam)
+;;
+;; The initial version was adapted from David Wilson:
;; https://systemcrafters.net/build-a-second-brain-in-emacs/5-org-roam-hacks/
;;; Code:
@@ -190,6 +198,51 @@ Otherwise return TEXT unchanged."
(or description url))
text))
+(defun cj/--generate-roam-slug (title)
+ "Convert TITLE to a filename-safe slug.
+Converts to lowercase, replaces non-alphanumeric characters with hyphens,
+and removes leading/trailing hyphens.
+Returns the slugified string."
+ (let ((slug (replace-regexp-in-string
+ "[^a-zA-Z0-9]+" "-"
+ (downcase title))))
+ (replace-regexp-in-string "^-\\|-$" "" slug)))
+
+(defun cj/--demote-org-subtree (content from-level to-level)
+ "Demote org subtree CONTENT from FROM-LEVEL to TO-LEVEL.
+CONTENT is the org-mode text with headings.
+FROM-LEVEL is the current level of the top heading (integer).
+TO-LEVEL is the desired level for the top heading (integer).
+Returns the demoted content as a string.
+All headings in the tree are adjusted proportionally."
+ (if (<= from-level to-level)
+ ;; No demotion needed
+ content
+ (let ((demote-count (- from-level to-level)))
+ (with-temp-buffer
+ (insert content)
+ (goto-char (point-min))
+ (while (re-search-forward "^\\(\\*+\\) " nil t)
+ (let* ((stars (match-string 1))
+ (level (length stars))
+ (new-level (max 1 (- level demote-count)))
+ (new-stars (make-string new-level ?*)))
+ (replace-match (concat new-stars " "))))
+ (buffer-string)))))
+
+(defun cj/--format-roam-node (title node-id content)
+ "Format org-roam node file CONTENT with TITLE and NODE-ID.
+TITLE is the node title string.
+NODE-ID is the unique identifier for the node.
+CONTENT is the main body content (already demoted if needed).
+Returns the complete file content as a string."
+ (concat ":PROPERTIES:\n"
+ ":ID: " node-id "\n"
+ ":END:\n"
+ "#+TITLE: " title "\n"
+ "#+CATEGORY: " title "\n"
+ "#+FILETAGS: Topic\n\n"
+ content))
(defun cj/move-org-branch-to-roam ()
"Move the org subtree at point to a new org-roam node.
@@ -213,12 +266,7 @@ title."
(title (cj/org-link-get-description raw-title))
(timestamp (format-time-string "%Y%m%d%H%M%S"))
;; Convert title to filename-safe format
- (title-slug (replace-regexp-in-string
- "[^a-zA-Z0-9]+" "-"
- (downcase title)))
- ;; Remove leading/trailing hyphens
- (title-slug (replace-regexp-in-string
- "^-\\|-$" "" title-slug))
+ (title-slug (cj/--generate-roam-slug title))
(filename (format "%s-%s.org" timestamp title-slug))
(filepath (expand-file-name filename org-roam-directory))
;; Generate a unique ID for the node
@@ -234,33 +282,11 @@ title."
(org-cut-subtree)
;; Process the subtree to demote it to level 1
- (with-temp-buffer
- (org-mode)
- (insert subtree-content)
- ;; Demote the entire tree so the top level becomes level 1
- (goto-char (point-min))
- (when (> current-level 1)
- (let ((demote-count (- current-level 1)))
- (while (re-search-forward "^\\*+ " nil t)
- (beginning-of-line)
- (dotimes (_ demote-count)
- (when (looking-at "^\\*\\*")
- (delete-char 1)))
- (forward-line))))
- (setq subtree-content (buffer-string)))
+ (setq subtree-content (cj/--demote-org-subtree subtree-content current-level 1))
;; Create the new org-roam file
(with-temp-file filepath
- ;; Insert the org-roam template with ID at file level
- (insert ":PROPERTIES:\n")
- (insert ":ID: " node-id "\n")
- (insert ":END:\n")
- (insert "#+TITLE: " title "\n")
- (insert "#+CATEGORY: " title "\n")
- (insert "#+FILETAGS: Topic\n\n")
-
- ;; Insert the demoted subtree content
- (insert subtree-content))
+ (insert (cj/--format-roam-node title node-id subtree-content)))
;; Sync the org-roam database
(org-roam-db-sync)
diff --git a/modules/org-webclipper.el b/modules/org-webclipper.el
index e8f2cf23..7b024e43 100644
--- a/modules/org-webclipper.el
+++ b/modules/org-webclipper.el
@@ -11,6 +11,7 @@
;; - Automatic conversion to Org format using eww-readable and Pandoc
;; - One-click capture from any web page
;; - Preserves page structure and formatting
+;; - Smart heading adjustment (removes page title, demotes remaining headings)
;;
;; Setup:
;; 1. Ensure this file is loaded in your Emacs configuration
@@ -30,6 +31,11 @@
;; The clipped content will be added to the file specified by `webclipped-file`
;; under the "Webclipped Inbox" heading with proper formatting and metadata.
;;
+;; Architecture:
+;; - cj/--process-webclip-content: Pure function for content processing
+;; - cj/org-protocol-webclip-handler: Handles URL fetching and capture
+;; - cj/org-webclipper-EWW: Direct capture from EWW/W3M buffers
+;;
;; Requirements:
;; - org-web-tools package
;; - Pandoc installed on your system
@@ -37,23 +43,6 @@
;;; Code:
-;; Declare functions and variables to avoid warnings
-(declare-function org-protocol-protocol-alist "org-protocol")
-(declare-function org-capture "org-capture")
-(declare-function org-capture-get "org-capture")
-(declare-function org-web-tools--url-as-readable-org "org-web-tools")
-(declare-function org-w3m-copy-for-org-mode "org-w3m")
-(declare-function org-eww-copy-for-org-mode "org-eww")
-(declare-function org-at-heading-p "org")
-(declare-function org-heading-components "org")
-(declare-function org-copy-subtree "org")
-(declare-function org-cut-subtree "org")
-(declare-function org-id-new "org-id")
-(declare-function org-roam-db-sync "org-roam")
-(defvar org-capture-templates)
-(defvar org-protocol-protocol-alist)
-(defvar org-roam-directory)
-(defvar webclipped-file)
;; Variables for storing org-protocol data
(defvar cj/webclip-current-url nil
@@ -66,6 +55,9 @@
(defvar cj/webclipper-initialized nil
"Track if webclipper has been initialized.")
+(use-package org-web-tools
+ :defer t)
+
;; Lazy initialization function
(defun cj/webclipper-ensure-initialized ()
"Ensure webclipper is initialized when first used."
@@ -73,6 +65,7 @@
;; Load required packages now
(require 'org-protocol)
(require 'org-capture)
+ (require 'org-web-tools)
(require 'user-constants) ;; for webclipped-file
;; Register the org-protocol handler
@@ -102,7 +95,28 @@
(setq cj/webclipper-initialized t)))
-;;;###autoload
+(defun cj/--process-webclip-content (org-content)
+ "Process webclip ORG-CONTENT by removing first heading and demoting others.
+ORG-CONTENT is the raw org-mode text from the web page conversion.
+Returns the processed content as a string with:
+- First top-level heading removed
+- Initial blank lines removed
+- All remaining headings demoted by one level"
+ (with-temp-buffer
+ (insert org-content)
+ (goto-char (point-min))
+ ;; Skip the first heading line (we'll use our template's heading)
+ (when (looking-at "^\\* .*\n")
+ (delete-region (match-beginning 0) (match-end 0)))
+ ;; Remove any initial blank lines
+ (while (looking-at "^[ \t]*\n")
+ (delete-char 1))
+ ;; Demote all remaining headings by one level
+ ;; since our template already provides the top-level heading
+ (while (re-search-forward "^\\(\\*+\\) " nil t)
+ (replace-match (concat (match-string 1) "* ") t t))
+ (buffer-string)))
+
(defun cj/org-protocol-webclip (info)
"Process org-protocol webclip requests.
INFO is a plist containing :url and :title from the org-protocol call."
@@ -135,22 +149,7 @@ It fetches the page content and converts it to Org format."
(error "No URL provided for clipping")
(condition-case err
(let* ((org-content (org-web-tools--url-as-readable-org url))
- ;; Process the content to adjust heading levels
- (processed-content
- (with-temp-buffer
- (insert org-content)
- (goto-char (point-min))
- ;; Skip the first heading line (we'll use our template's heading)
- (when (looking-at "^\\* .*\n")
- (delete-region (match-beginning 0) (match-end 0)))
- ;; Remove any initial blank lines
- (while (looking-at "^[ \t]*\n")
- (delete-char 1))
- ;; Demote all remaining headings by one level
- ;; since our template already provides the top-level heading
- (while (re-search-forward "^\\(\\*+\\) " nil t)
- (replace-match (concat (match-string 1) "* ") t t))
- (buffer-string))))
+ (processed-content (cj/--process-webclip-content org-content)))
;; Show success message with the title
(require 'user-constants) ;; Ensure webclipped-file is available
(message "'%s' added to %s" title webclipped-file)
@@ -162,7 +161,7 @@ It fetches the page content and converts it to Org format."
;; ---------------------------- Org Webpage Clipper ----------------------------
-;;;###autoload
+
(defun cj/org-webclipper-EWW ()
"Capture the current web page for later viewing in an Org file.
Return the yanked content as a string so templates can insert it."
@@ -182,13 +181,11 @@ Return the yanked content as a string so templates can insert it."
;; extract the webpage content from the kill ring
(car kill-ring)))
-
;; ----------------------------- Webclipper Keymap -----------------------------
;; keymaps shouldn't be required for webclipper
-;; TASK Move org-branch to roam functionality under org-roam
;; Setup keymaps
-;; ;;;###autoload
+;;
;; (defun cj/webclipper-setup-keymaps ()
;; "Setup webclipper keymaps."
;; (define-prefix-command 'cj/webclipper-map nil
@@ -201,7 +198,6 @@ Return the yanked content as a string so templates can insert it."
;; (cj/webclipper-setup-keymaps))
;; Register protocol handler early for external calls
-;;;###autoload
(with-eval-after-load 'org-protocol
(unless (assoc "webclip" org-protocol-protocol-alist)
(add-to-list 'org-protocol-protocol-alist
@@ -210,9 +206,9 @@ Return the yanked content as a string so templates can insert it."
:function cj/org-protocol-webclip
:kill-client t))))
-(with-eval-after-load 'cj/custom-keymap
- (require 'org-webclipper)
- (cj/webclipper-setup-keymaps))
+;; (with-eval-after-load 'cj/custom-keymap
+;; (require 'org-webclipper)
+;; (cj/webclipper-setup-keymaps))
(provide 'org-webclipper)
;;; org-webclipper.el ends here
diff --git a/modules/reconcile-open-repos.el b/modules/reconcile-open-repos.el
index 648de222..2e48e45d 100644
--- a/modules/reconcile-open-repos.el
+++ b/modules/reconcile-open-repos.el
@@ -73,7 +73,6 @@ Magit for review."
;; ---------------------------- Check For Open Work ----------------------------
-;;;###autoload
(defun cj/check-for-open-work ()
"Check all project directories for open work."
(interactive)
diff --git a/modules/test-runner.el b/modules/test-runner.el
index b4c40820..125a8d20 100644
--- a/modules/test-runner.el
+++ b/modules/test-runner.el
@@ -2,26 +2,75 @@
;; author: Craig Jennings <c@cjennings.net>
;;
;;; Commentary:
-;; Provides utilities for running ERT tests with focus/unfocus workflow
+
+;; This module provides a powerful ERT test runner with focus/unfocus workflow
+;; for efficient test-driven development in Emacs Lisp projects.
+;;
+;; PURPOSE:
+;;
+;; When working on large Emacs Lisp projects with many test files, you often
+;; want to focus on running just the tests relevant to your current work without
+;; waiting for the entire suite to run. This module provides a smart test runner
+;; that supports both running all tests and focusing on specific test files.
+;;
+;; WORKFLOW:
+;;
+;; 1. Run all tests initially to establish baseline (C-; t R)
+;; 2. Add test files to focus while working on a feature (C-; t a)
+;; 3. Run focused tests repeatedly as you develop (C-; t r)
+;; 4. Add more test files as needed (C-; t b from within test buffer)
+;; 5. View your focused test list at any time (C-; t v)
+;; 6. Clear focus and run all tests before finishing (C-; t c, then C-; t R)
+;;
+;; PROJECT INTEGRATION:
;;
-;; Tests should be located in the Projectile project test directories,
-;; typically "test" or "tests" under the project root.
-;; Falls back to =~/.emacs.d/tests= if not in a Projectile project.
+;; - Automatically discovers test directories in Projectile projects
+;; (looks for "test" or "tests" under project root)
+;; - Falls back to ~/.emacs.d/tests if not in a Projectile project
+;; - Test files must match pattern: test-*.el
;;
-;; The default mode is to load and run all tests.
+;; SPECIAL BEHAVIORS:
;;
-;; To focus on running a specific set of test files:
-;; - Toggle the mode to "focus" mode
-;; - Add specific test files to the list of tests in "focus"
-;; - Running tests (smartly) will now just run those tests
+;; - Smart test running: Automatically runs all or focused tests based on mode
+;; - Test extraction: Discovers test names via regex to run specific tests
+;; - At-point execution: Run individual test at cursor position (C-; t .)
+;; - Error handling: Continues loading tests even if individual files fail
;;
-;; Don't forget to run all tests again in default mode at least once before finishing.
+;; KEYBINDINGS:
+;;
+;; C-; t L Load all test files
+;; C-; t R Run all tests (full suite)
+;; C-; t r Run tests smartly (all or focused based on mode)
+;; C-; t . Run test at point
+;; C-; t a Add test file to focus (with completion)
+;; C-; t b Add current buffer's test file to focus
+;; C-; t c Clear all focused test files
+;; C-; t v View list of focused test files
+;; C-; t t Toggle mode between 'all and 'focused
+;;
+;; RECOMMENDED USAGE:
+;;
+;; While implementing a feature:
+;; - Add the main test file for the feature you're working on
+;; - Add any related test files that might be affected
+;; - Use C-; t r to repeatedly run just those focused tests
+;; - This provides fast feedback during development
+;;
+;; Before committing:
+;; - Clear the focus with C-; t c
+;; - Run the full suite with C-; t R to ensure nothing broke
+;; - Verify all tests pass before pushing changes
;;
;;; Code:
(require 'ert)
(require 'cl-lib)
+;;; External Variables and Functions
+
+(defvar cj/custom-keymap) ; Defined in init.el
+(declare-function projectile-project-root "projectile" ())
+
;;; Variables
(defvar cj/test-global-directory nil
@@ -35,19 +84,19 @@ Each element is a filename (without path) to run.")
(defvar cj/test-mode 'all
"Current test execution mode.
-Either 'all (run all tests) or 'focused (run only focused tests).")
+Either \\='all (run all tests) or \\='focused (run only focused tests).")
(defvar cj/test-last-results nil
"Results from the last test run.")
;;; Core Functions
-;;;###autoload
(defun cj/test--get-test-directory ()
"Return the test directory path for the current project.
-If in a Projectile project, prefers a 'test' or 'tests' directory inside the project root.
-Falls back to =cj/test-global-directory= if not found or not in a project."
+If in a Projectile project, prefers \\='test or \\='tests directory
+inside the project root. Falls back to `cj/test-global-directory'
+if not found or not in a project."
(require 'projectile)
(let ((project-root (ignore-errors (projectile-project-root))))
(if (not (and project-root (file-directory-p project-root)))
@@ -60,15 +109,32 @@ Falls back to =cj/test-global-directory= if not found or not in a project."
((file-directory-p tests-dir) tests-dir)
(t cj/test-global-directory))))))
-;;;###autoload
(defun cj/test--get-test-files ()
- "Return a list of test file names (without path) in the appropriate test directory."
+ "Return list of test file names (without path) in test directory."
(let ((dir (cj/test--get-test-directory)))
(when (file-directory-p dir)
(mapcar #'file-name-nondirectory
(directory-files dir t "^test-.*\\.el$")))))
-;;;###autoload
+(defun cj/test--do-load-files (_dir files)
+ "Load test FILES from DIR.
+Returns: (cons \\='success loaded-count) on success,
+ (cons \\='error (list failed-files errors)) on errors."
+ (let ((loaded-count 0)
+ (errors '()))
+ (dolist (file files)
+ (condition-case err
+ (progn
+ (load-file file)
+ (setq loaded-count (1+ loaded-count)))
+ (error
+ (push (cons (file-name-nondirectory file)
+ (error-message-string err))
+ errors))))
+ (if (null errors)
+ (cons 'success loaded-count)
+ (cons 'error (list loaded-count (nreverse errors))))))
+
(defun cj/test-load-all ()
"Load all test files from the appropriate test directory."
(interactive)
@@ -76,21 +142,27 @@ Falls back to =cj/test-global-directory= if not found or not in a project."
(let ((dir (cj/test--get-test-directory)))
(unless (file-directory-p dir)
(user-error "Test directory %s does not exist" dir))
- (let ((test-files (directory-files dir t "^test-.*\\.el$"))
- (loaded-count 0))
- (dolist (file test-files)
- (condition-case err
- (progn
- (load-file file)
- (setq loaded-count (1+ loaded-count))
- (message "Loaded test file: %s" (file-name-nondirectory file)))
- (error
- (message "Error loading %s: %s"
- (file-name-nondirectory file)
- (error-message-string err)))))
- (message "Loaded %d test file(s)" loaded-count))))
-
-;;;###autoload
+ (let ((test-files (directory-files dir t "^test-.*\\.el$")))
+ (pcase (cj/test--do-load-files dir test-files)
+ (`(success . ,count)
+ (message "Loaded %d test file(s)" count))
+ (`(error ,count ,errors)
+ (dolist (err errors)
+ (message "Error loading %s: %s" (car err) (cdr err)))
+ (message "Loaded %d test file(s) with %d error(s)" count (length errors)))))))
+
+(defun cj/test--do-focus-add (filename available-files focused-files)
+ "Add FILENAME to focused test files.
+AVAILABLE-FILES is the list of all available test files.
+FOCUSED-FILES is the current list of focused files.
+Returns: \\='success if added successfully,
+ \\='already-focused if file is already focused,
+ \\='not-available if file is not in available-files."
+ (cond
+ ((not (member filename available-files)) 'not-available)
+ ((member filename focused-files) 'already-focused)
+ (t 'success)))
+
(defun cj/test-focus-add ()
"Select test file(s) to add to the focused list."
(interactive)
@@ -109,30 +181,64 @@ Falls back to =cj/test-global-directory= if not found or not in a project."
unfocused-files
nil t)
(user-error "All test files are already focused"))))
- (push selected cj/test-focused-files)
- (message "Added to focus: %s" selected)
- (when (called-interactively-p 'interactive)
- (cj/test-view-focused))))))
+ (pcase (cj/test--do-focus-add selected available-files cj/test-focused-files)
+ ('success
+ (push selected cj/test-focused-files)
+ (message "Added to focus: %s" selected)
+ (when (called-interactively-p 'interactive)
+ (cj/test-view-focused)))
+ ('already-focused
+ (message "Already focused: %s" selected))
+ ('not-available
+ (user-error "File not available: %s" selected)))))))
+
+(defun cj/test--do-focus-add-file (filepath testdir focused-files)
+ "Validate and add FILEPATH to focused list.
+TESTDIR is the test directory path.
+FOCUSED-FILES is the current list of focused files.
+Returns: \\='success if added successfully,
+ \\='no-file if filepath is nil,
+ \\='not-in-testdir if file is not inside test directory,
+ \\='already-focused if file is already focused.
+Second value is the relative filename if successful."
+ (cond
+ ((null filepath) (cons 'no-file nil))
+ ((not (string-prefix-p (file-truename testdir) (file-truename filepath)))
+ (cons 'not-in-testdir nil))
+ (t
+ (let ((relative (file-relative-name filepath testdir)))
+ (if (member relative focused-files)
+ (cons 'already-focused relative)
+ (cons 'success relative))))))
-;;;###autoload
(defun cj/test-focus-add-this-buffer-file ()
"Add the current buffer's file to the focused test list."
(interactive)
(let ((file (buffer-file-name))
(dir (cj/test--get-test-directory)))
- (unless file
- (user-error "Current buffer is not visiting a file"))
- (unless (string-prefix-p (file-truename dir) (file-truename file))
- (user-error "File is not inside the test directory: %s" dir))
- (let ((relative (file-relative-name file dir)))
- (if (member relative cj/test-focused-files)
- (message "Already focused: %s" relative)
- (push relative cj/test-focused-files)
- (message "Added to focus: %s" relative)
- (when (called-interactively-p 'interactive)
- (cj/test-view-focused))))))
-
-;;;###autoload
+ (pcase (cj/test--do-focus-add-file file dir cj/test-focused-files)
+ (`(no-file . ,_)
+ (user-error "Current buffer is not visiting a file"))
+ (`(not-in-testdir . ,_)
+ (user-error "File is not inside the test directory: %s" dir))
+ (`(already-focused . ,relative)
+ (message "Already focused: %s" relative))
+ (`(success . ,relative)
+ (push relative cj/test-focused-files)
+ (message "Added to focus: %s" relative)
+ (when (called-interactively-p 'interactive)
+ (cj/test-view-focused))))))
+
+(defun cj/test--do-focus-remove (filename focused-files)
+ "Remove FILENAME from FOCUSED-FILES.
+Returns: \\='success if removed successfully,
+ \\='empty-list if focused-files is empty,
+ \\='not-found if filename is not in focused-files."
+ (cond
+ ((null focused-files) 'empty-list)
+ ((not (member filename focused-files)) 'not-found)
+ (t 'success)))
+
(defun cj/test-focus-remove ()
"Remove a test file from the focused list."
(interactive)
@@ -141,13 +247,18 @@ Falls back to =cj/test-global-directory= if not found or not in a project."
(let ((selected (completing-read "Remove from focus: "
cj/test-focused-files
nil t)))
- (setq cj/test-focused-files
- (delete selected cj/test-focused-files))
- (message "Removed from focus: %s" selected)
- (when (called-interactively-p 'interactive)
- (cj/test-view-focused)))))
+ (pcase (cj/test--do-focus-remove selected cj/test-focused-files)
+ ('success
+ (setq cj/test-focused-files
+ (delete selected cj/test-focused-files))
+ (message "Removed from focus: %s" selected)
+ (when (called-interactively-p 'interactive)
+ (cj/test-view-focused)))
+ ('not-found
+ (message "File not in focused list: %s" selected))
+ ('empty-list
+ (user-error "No focused files to remove"))))))
-;;;###autoload
(defun cj/test-focus-clear ()
"Clear all focused test files."
(interactive)
@@ -168,73 +279,82 @@ Returns a list of test name symbols defined in the file."
(push (match-string 1) test-names)))
test-names))
-;;;###autoload
+(defun cj/test--do-get-focused-tests (focused-files test-dir)
+ "Get test names from FOCUSED-FILES in TEST-DIR.
+Returns: (cons \\='success (list test-names loaded-count)) if successful,
+ (cons \\='no-tests nil) if no tests found,
+ (cons \\='empty-list nil) if focused-files is empty."
+ (if (null focused-files)
+ (cons 'empty-list nil)
+ (let ((all-test-names '())
+ (loaded-count 0))
+ (dolist (file focused-files)
+ (let ((full-path (expand-file-name file test-dir)))
+ (when (file-exists-p full-path)
+ (load-file full-path)
+ (setq loaded-count (1+ loaded-count))
+ (let ((test-names (cj/test--extract-test-names full-path)))
+ (setq all-test-names (append all-test-names test-names))))))
+ (if (null all-test-names)
+ (cons 'no-tests nil)
+ (cons 'success (list all-test-names loaded-count))))))
+
(defun cj/test-run-focused ()
"Run only the focused test files."
(interactive)
- (if (null cj/test-focused-files)
- (user-error "No focused files set. Use =cj/test-focus-add' first")
- (let ((all-test-names '())
- (loaded-count 0)
- (dir (cj/test--get-test-directory)))
- ;; Load the focused files and collect their test names
- (dolist (file cj/test-focused-files)
- (let ((full-path (expand-file-name file dir)))
- (when (file-exists-p full-path)
- (load-file full-path)
- (setq loaded-count (1+ loaded-count))
- ;; Extract test names from this file
- (let ((test-names (cj/test--extract-test-names full-path)))
- (setq all-test-names (append all-test-names test-names))))))
- (if (null all-test-names)
- (message "No tests found in focused files")
- ;; Build a regexp that matches any of our test names
- (let ((pattern (regexp-opt all-test-names)))
- (message "Running %d test(s) from %d focused file(s)"
- (length all-test-names) loaded-count)
- ;; Run only the tests we found
- (ert (concat "^" pattern "$")))))))
+ (let ((dir (cj/test--get-test-directory)))
+ (pcase (cj/test--do-get-focused-tests cj/test-focused-files dir)
+ (`(empty-list . ,_)
+ (user-error "No focused files set. Use =cj/test-focus-add' first"))
+ (`(no-tests . ,_)
+ (message "No tests found in focused files"))
+ (`(success ,test-names ,loaded-count)
+ (let ((pattern (regexp-opt test-names)))
+ (message "Running %d test(s) from %d focused file(s)"
+ (length test-names) loaded-count)
+ (ert (concat "^" pattern "$")))))))
(defun cj/test--ensure-test-dir-in-load-path ()
- "Ensure the directory returned by cj/test--get-test-directory is in `load-path`."
+ "Ensure test directory is in `load-path'."
(let ((dir (cj/test--get-test-directory)))
(when (and dir (file-directory-p dir))
(add-to-list 'load-path dir))))
-;;;###autoload
+(defun cj/test--extract-test-at-pos ()
+ "Extract test name at current position.
+Returns: test name symbol if found, nil otherwise."
+ (save-excursion
+ (beginning-of-defun)
+ (condition-case nil
+ (let ((form (read (current-buffer))))
+ (when (and (listp form)
+ (eq (car form) 'ert-deftest)
+ (symbolp (cadr form)))
+ (cadr form)))
+ (error nil))))
+
(defun cj/run-test-at-point ()
"Run the ERT test at point.
If point is inside an `ert-deftest` definition, run that test only.
Otherwise, message that no test is found."
(interactive)
- (let ((original-point (point)))
- (save-excursion
- (beginning-of-defun)
- (condition-case nil
- (let ((form (read (current-buffer))))
- (if (and (listp form)
- (eq (car form) 'ert-deftest)
- (symbolp (cadr form)))
- (ert (cadr form))
- (message "Not in an ERT test method.")))
- (error (message "No ERT test methods found at point."))))
- (goto-char original-point)))
-
-;;;###autoload
+ (let ((test-name (cj/test--extract-test-at-pos)))
+ (if test-name
+ (ert test-name)
+ (message "Not in an ERT test method."))))
+
(defun cj/test-run-all ()
"Load and run all tests."
(interactive)
(cj/test-load-all)
(ert t))
-;;;###autoload
(defun cj/test-toggle-mode ()
- "Toggle between 'all and 'focused test execution modes."
+ "Toggle between \\='all and \\='focused test execution modes."
(interactive)
(setq cj/test-mode (if (eq cj/test-mode 'all) 'focused 'all))
(message "Test mode: %s" cj/test-mode))
-;;;###autoload
(defun cj/test-view-focused ()
"Display test files in focus."
(interactive)
@@ -243,7 +363,6 @@ Otherwise, message that no test is found."
(message "Focused files: %s"
(mapconcat 'identity cj/test-focused-files ", "))))
-;;;###autoload
(defun cj/test-run-smart ()
"Run tests based on current mode (all or focused)."
(interactive)
@@ -265,8 +384,20 @@ Otherwise, message that no test is found."
"t" #'cj/test-toggle-mode)
(keymap-set cj/custom-keymap "t" cj/testrunner-map)
+
+;; which-key integration
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; t" "test runner menu"))
+ (which-key-add-key-based-replacements
+ "C-; t" "test runner menu"
+ "C-; t L" "load all tests"
+ "C-; t R" "run all tests"
+ "C-; t r" "run smart"
+ "C-; t ." "run test at point"
+ "C-; t a" "add to focus"
+ "C-; t b" "add buffer to focus"
+ "C-; t c" "clear focus"
+ "C-; t v" "view focused"
+ "C-; t t" "toggle mode"))
(provide 'test-runner)
;;; test-runner.el ends here
diff --git a/modules/wip.el b/modules/wip.el
index 314881d2..80b3295d 100644
--- a/modules/wip.el
+++ b/modules/wip.el
@@ -35,7 +35,6 @@
(list nil s "command")))
(t (user-error "Error: cj/system-cmd expects a string or a symbol"))))
-;;;###autoload
(defun cj/system-cmd (cmd)
"Run CMD (string or symbol naming a string) detached via the shell.
Shell expansions like $(...) are supported. Output is silenced.
diff --git a/tests/test-browser-config.el b/tests/test-browser-config.el
new file mode 100644
index 00000000..6ab756dd
--- /dev/null
+++ b/tests/test-browser-config.el
@@ -0,0 +1,277 @@
+;;; test-browser-config.el --- Tests for browser-config.el -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for browser-config.el - browser selection and configuration.
+;;
+;; Testing approach:
+;; - Tests focus on internal `cj/--do-*` functions (pure business logic)
+;; - File I/O tests use temp files
+;; - executable-find is stubbed to control available browsers
+;; - Each test is isolated with setup/teardown
+;; - Tests verify return values, not user messages
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Load the module with temp file to avoid polluting real config
+(defvar test-browser--temp-choice-file nil
+ "Temporary file for browser choice during tests.")
+
+(defun test-browser-setup ()
+ "Setup test environment before each test."
+ (setq test-browser--temp-choice-file (make-temp-file "browser-choice-test" nil ".el"))
+ (setq cj/browser-choice-file test-browser--temp-choice-file))
+
+(defun test-browser-teardown ()
+ "Clean up test environment after each test."
+ (when (and test-browser--temp-choice-file
+ (file-exists-p test-browser--temp-choice-file))
+ (delete-file test-browser--temp-choice-file))
+ (setq test-browser--temp-choice-file nil))
+
+;; Now require the module
+(require 'browser-config)
+
+;;; Helper Functions
+
+(defun test-browser-make-plist (name &optional executable path)
+ "Create a test browser plist with NAME, EXECUTABLE, and PATH."
+ (list :function 'eww-browse-url
+ :name name
+ :executable executable
+ :path path
+ :program-var nil))
+
+;;; Normal Cases - Discover Browsers
+
+(ert-deftest test-browser-discover-finds-eww ()
+ "Should always find built-in EWW browser."
+ (test-browser-setup)
+ (let ((browsers (cj/discover-browsers)))
+ (should (cl-find-if (lambda (b) (string= (plist-get b :name) "EWW (Emacs Browser)"))
+ browsers)))
+ (test-browser-teardown))
+
+(ert-deftest test-browser-discover-deduplicates-names ()
+ "Should not return duplicate browser names."
+ (test-browser-setup)
+ (let ((browsers (cj/discover-browsers))
+ (names (mapcar (lambda (b) (plist-get b :name)) (cj/discover-browsers))))
+ (should (= (length names) (length (cl-remove-duplicates names :test 'string=)))))
+ (test-browser-teardown))
+
+;;; Normal Cases - Apply Browser Choice
+
+(ert-deftest test-browser-apply-valid-browser ()
+ "Should successfully apply a valid browser configuration."
+ (test-browser-setup)
+ (let ((browser (test-browser-make-plist "Test Browser")))
+ (let ((result (cj/--do-apply-browser-choice browser)))
+ (should (eq result 'success))
+ (should (eq browse-url-browser-function 'eww-browse-url))))
+ (test-browser-teardown))
+
+(ert-deftest test-browser-apply-sets-program-var ()
+ "Should set browser program variable if specified."
+ (test-browser-setup)
+ (let ((browser (list :function 'browse-url-chrome
+ :name "Chrome"
+ :executable "chrome"
+ :path "/usr/bin/chrome"
+ :program-var 'browse-url-chrome-program)))
+ (cj/--do-apply-browser-choice browser)
+ (should (string= browse-url-chrome-program "/usr/bin/chrome")))
+ (test-browser-teardown))
+
+;;; Normal Cases - Save and Load
+
+(ert-deftest test-browser-save-and-load-choice ()
+ "Should save and load browser choice correctly."
+ (test-browser-setup)
+ (let ((browser (test-browser-make-plist "Saved Browser" "firefox" "/usr/bin/firefox")))
+ (cj/save-browser-choice browser)
+ (let ((loaded (cj/load-browser-choice)))
+ (should loaded)
+ (should (string= (plist-get loaded :name) "Saved Browser"))
+ (should (string= (plist-get loaded :executable) "firefox"))))
+ (test-browser-teardown))
+
+;;; Normal Cases - Choose Browser
+
+(ert-deftest test-browser-choose-saves-and-applies ()
+ "Should save and apply browser choice."
+ (test-browser-setup)
+ (let ((browser (test-browser-make-plist "Test")))
+ (let ((result (cj/--do-choose-browser browser)))
+ (should (eq result 'success))
+ ;; Verify it was saved
+ (let ((loaded (cj/load-browser-choice)))
+ (should (string= (plist-get loaded :name) "Test")))))
+ (test-browser-teardown))
+
+;;; Normal Cases - Initialize Browser
+
+(ert-deftest test-browser-initialize-with-saved-choice ()
+ "Should load and use saved browser choice."
+ (test-browser-setup)
+ (let ((browser (test-browser-make-plist "Saved")))
+ (cj/save-browser-choice browser)
+ (let ((result (cj/--do-initialize-browser)))
+ (should (eq (car result) 'loaded))
+ (should (plist-get (cdr result) :name))
+ (should (string= (plist-get (cdr result) :name) "Saved"))))
+ (test-browser-teardown))
+
+(ert-deftest test-browser-initialize-without-saved-choice ()
+ "Should use first available browser when no saved choice."
+ (test-browser-setup)
+ ;; Delete any saved choice
+ (when (file-exists-p cj/browser-choice-file)
+ (delete-file cj/browser-choice-file))
+ (let ((result (cj/--do-initialize-browser)))
+ (should (eq (car result) 'first-available))
+ (should (plist-get (cdr result) :name)))
+ (test-browser-teardown))
+
+;;; Boundary Cases - Apply Browser
+
+(ert-deftest test-browser-apply-nil-plist ()
+ "Should return 'invalid-plist for nil browser."
+ (test-browser-setup)
+ (let ((result (cj/--do-apply-browser-choice nil)))
+ (should (eq result 'invalid-plist)))
+ (test-browser-teardown))
+
+(ert-deftest test-browser-apply-missing-function ()
+ "Should return 'invalid-plist when :function is missing."
+ (test-browser-setup)
+ (let ((browser (list :name "Bad Browser" :function nil)))
+ (let ((result (cj/--do-apply-browser-choice browser)))
+ (should (eq result 'invalid-plist))))
+ (test-browser-teardown))
+
+(ert-deftest test-browser-apply-with-nil-path ()
+ "Should handle nil path for built-in browser."
+ (test-browser-setup)
+ (let ((browser (test-browser-make-plist "EWW" nil nil)))
+ (let ((result (cj/--do-apply-browser-choice browser)))
+ (should (eq result 'success))))
+ (test-browser-teardown))
+
+;;; Boundary Cases - Save and Load
+
+(ert-deftest test-browser-load-nonexistent-file ()
+ "Should return nil when loading from nonexistent file."
+ (test-browser-setup)
+ (when (file-exists-p cj/browser-choice-file)
+ (delete-file cj/browser-choice-file))
+ (let ((result (cj/load-browser-choice)))
+ (should (null result)))
+ (test-browser-teardown))
+
+(ert-deftest test-browser-load-corrupt-file ()
+ "Should return nil when loading corrupt file."
+ (test-browser-setup)
+ (with-temp-file cj/browser-choice-file
+ (insert "this is not valid elisp {{{"))
+ (let ((result (cj/load-browser-choice)))
+ (should (null result)))
+ (test-browser-teardown))
+
+(ert-deftest test-browser-load-file-without-variable ()
+ "Should return nil when file doesn't define expected variable."
+ (test-browser-setup)
+ (with-temp-file cj/browser-choice-file
+ (insert "(setq some-other-variable 'foo)"))
+ ;; Unset any previously loaded variable
+ (makunbound 'cj/saved-browser-choice)
+ (let ((result (cj/load-browser-choice)))
+ (should (null result)))
+ (test-browser-teardown))
+
+;;; Boundary Cases - Choose Browser
+
+(ert-deftest test-browser-choose-empty-plist ()
+ "Should handle empty plist gracefully."
+ (test-browser-setup)
+ (let ((result (cj/--do-choose-browser nil)))
+ (should (eq result 'invalid-plist)))
+ (test-browser-teardown))
+
+;;; Error Cases - File Operations
+
+(ert-deftest test-browser-save-to-readonly-location ()
+ "Should return 'save-failed when cannot write file."
+ (test-browser-setup)
+ ;; Make file read-only
+ (with-temp-file cj/browser-choice-file
+ (insert ";; test"))
+ (set-file-modes cj/browser-choice-file #o444)
+ (let ((browser (test-browser-make-plist "Test"))
+ (result nil))
+ (setq result (cj/--do-choose-browser browser))
+ ;; Restore permissions before teardown
+ (set-file-modes cj/browser-choice-file #o644)
+ (should (eq result 'save-failed)))
+ (test-browser-teardown))
+
+;;; Browser Discovery Tests
+
+(ert-deftest test-browser-discover-returns-plists ()
+ "Should return properly formatted browser plists."
+ (test-browser-setup)
+ (let ((browsers (cj/discover-browsers)))
+ (should (> (length browsers) 0))
+ (dolist (browser browsers)
+ (should (plist-member browser :function))
+ (should (plist-member browser :name))
+ (should (plist-member browser :executable))
+ (should (plist-member browser :path))))
+ (test-browser-teardown))
+
+(ert-deftest test-browser-format-location-keys ()
+ "Should have all required keys in browser plist."
+ (test-browser-setup)
+ (let ((browsers (cj/discover-browsers)))
+ (when browsers
+ (let ((browser (car browsers)))
+ (should (plist-get browser :function))
+ (should (plist-get browser :name)))))
+ (test-browser-teardown))
+
+;;; Integration Tests
+
+(ert-deftest test-browser-full-cycle ()
+ "Should handle full save-load-apply cycle."
+ (test-browser-setup)
+ (let ((browser (test-browser-make-plist "Cycle Test" "test-browser" "/usr/bin/test")))
+ ;; Choose (save and apply)
+ (should (eq (cj/--do-choose-browser browser) 'success))
+ ;; Verify it was saved
+ (let ((loaded (cj/load-browser-choice)))
+ (should loaded)
+ (should (string= (plist-get loaded :name) "Cycle Test")))
+ ;; Initialize should load the saved choice
+ (let ((result (cj/--do-initialize-browser)))
+ (should (eq (car result) 'loaded))
+ (should (string= (plist-get (cdr result) :name) "Cycle Test"))))
+ (test-browser-teardown))
+
+(ert-deftest test-browser-overwrite-choice ()
+ "Should overwrite previous browser choice."
+ (test-browser-setup)
+ (let ((browser1 (test-browser-make-plist "First"))
+ (browser2 (test-browser-make-plist "Second")))
+ (cj/--do-choose-browser browser1)
+ (cj/--do-choose-browser browser2)
+ (let ((loaded (cj/load-browser-choice)))
+ (should (string= (plist-get loaded :name) "Second"))))
+ (test-browser-teardown))
+
+(provide 'test-browser-config)
+;;; test-browser-config.el ends here
diff --git a/tests/test-clear-blank-lines.el.disabled b/tests/test-clear-blank-lines.el.disabled
deleted file mode 100644
index 2190aba0..00000000
--- a/tests/test-clear-blank-lines.el.disabled
+++ /dev/null
@@ -1,47 +0,0 @@
-;;; test-clear-blank-lines.el --- -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;;
-
-;;; Code:
-
-(require 'ert)
-(add-to-list 'load-path (concat user-emacs-directory "modules"))
-(require 'custom-functions)
-
-(ert-deftest test-cj/clear-blank-lines-region ()
- (let ((testdata "Some\n\n\n\nText")
- (expected "Some\nText")
- (actual))
- (with-temp-buffer
- (insert testdata)
- (cj/clear-blank-lines (point-min) (point-max))
- (setq actual (buffer-string))
- (message "buffer is:\n'%s'" actual)
- (should (string= actual expected)))))
-
-(ert-deftest test-cj/clear-blank-lines-region-multiple-lines ()
- (let ((testdata "Some\n\n\n\nText")
- (expected "Some\n\n\n\nText")
- (midpoint)
- (actual))
- (with-temp-buffer
- (insert testdata)
- (insert "\n")
- (setq midpoint (point))
- (insert testdata)
- (cj/clear-blank-lines (point-min) midpoint)
- (setq actual (buffer-substring (- (point-max)
- (length testdata)) (point-max)))
- (message "buffer is:\n'%s'" (buffer-string))
- (should (string= actual expected)))))
-
-(ert-deftest test-cj/clear-blank-lines-negative ()
- (with-temp-buffer
- (insert "Some\nText")
- (cj/clear-blank-lines (point-min) (point-max))
- (should (equal (buffer-string) "Some\nText"))))
-
-
-(provide 'test-clear-blank-lines)
-;;; test-clear-blank-lines.el ends here.
diff --git a/tests/test-custom-comments-comment-block-banner.el b/tests/test-custom-comments-comment-block-banner.el
new file mode 100644
index 00000000..6561ebfa
--- /dev/null
+++ b/tests/test-custom-comments-comment-block-banner.el
@@ -0,0 +1,228 @@
+;;; test-custom-comments-comment-block-banner.el --- Tests for cj/comment-block-banner -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/comment-block-banner function from custom-comments.el
+;;
+;; This function generates a 3-line block banner comment (JSDoc/Doxygen style):
+;; - Top line: comment-start (e.g., /*) + decoration chars
+;; - Text line: space + decoration char + space + text
+;; - Bottom line: space + decoration chars + comment-end (e.g., */)
+;;
+;; This style is common in C, JavaScript, Java, and other languages that use
+;; block comments.
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--comment-block-banner)
+;; to avoid mocking user prompts. This follows our testing best practice
+;; of separating business logic from UI interaction.
+;;
+;; Cross-Language Testing Strategy:
+;; - Comprehensive testing in C (the primary language for this style)
+;; - Representative testing in JavaScript/Java (similar block comment syntax)
+;; - This style is specifically designed for block comments, so we focus
+;; testing on languages that use /* */ syntax
+;; - See test-custom-comments-delete-buffer-comments.el for detailed rationale
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-comments)
+
+;;; Test Helpers
+
+(defun test-block-banner-at-column (column-pos comment-start comment-end decoration-char text length)
+ "Test cj/--comment-block-banner at COLUMN-POS indentation.
+Insert spaces to reach COLUMN-POS, then call cj/--comment-block-banner with
+COMMENT-START, COMMENT-END, DECORATION-CHAR, TEXT, and LENGTH.
+Returns the buffer string for assertions."
+ (with-temp-buffer
+ (when (> column-pos 0)
+ (insert (make-string column-pos ?\s)))
+ (cj/--comment-block-banner comment-start comment-end decoration-char text length)
+ (buffer-string)))
+
+;;; C/JavaScript/Java Tests (Block Comment Languages - Comprehensive Coverage)
+
+;;; Normal Cases
+
+(ert-deftest test-block-banner-c-basic ()
+ "Should generate 3-line block banner in C style."
+ (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "Section Header" 70)))
+ ;; Should have 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; First line should start with /*
+ (should (string-match-p "^/\\*\\*" result))
+ ;; Middle line should contain text
+ (should (string-match-p "\\* Section Header" result))
+ ;; Last line should end with */
+ (should (string-match-p "\\*/$" result))))
+
+(ert-deftest test-block-banner-c-custom-decoration ()
+ "Should use custom decoration character."
+ (let ((result (test-block-banner-at-column 0 "/*" "*/" "#" "Header" 70)))
+ (should (string-match-p "/\\*#" result))
+ (should (string-match-p " # Header" result))))
+
+(ert-deftest test-block-banner-c-custom-text ()
+ "Should include custom text in banner."
+ (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "Custom Text Here" 70)))
+ (should (string-match-p "Custom Text Here" result))))
+
+(ert-deftest test-block-banner-c-empty-text ()
+ "Should handle empty text string."
+ (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "" 70)))
+ ;; Should still generate 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Should have comment delimiters
+ (should (string-match-p "/\\*" result))
+ (should (string-match-p "\\*/$" result))))
+
+(ert-deftest test-block-banner-c-at-column-0 ()
+ "Should work at column 0."
+ (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "Header" 70)))
+ ;; First character should be /
+ (should (string-prefix-p "/*" result))))
+
+(ert-deftest test-block-banner-c-indented ()
+ "Should work when indented."
+ (let ((result (test-block-banner-at-column 4 "/*" "*/" "*" "Header" 70)))
+ ;; First line should start with spaces
+ (should (string-prefix-p " /*" result))
+ ;; Other lines should be indented
+ (let ((lines (split-string result "\n" t)))
+ (should (string-prefix-p " " (nth 1 lines))) ; text line has extra space
+ (should (string-prefix-p " " (nth 2 lines)))))) ; bottom line has extra space
+
+(ert-deftest test-block-banner-c-short-text ()
+ "Should handle short text properly."
+ (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "X" 70)))
+ ;; Should have 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Text should be present
+ (should (string-match-p "X" result))))
+
+(ert-deftest test-block-banner-c-long-text ()
+ "Should handle longer text."
+ (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "This is a longer header text" 70)))
+ ;; Should have 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Text should be present
+ (should (string-match-p "This is a longer header text" result))))
+
+(ert-deftest test-block-banner-c-custom-length ()
+ "Should respect custom length."
+ (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "Header" 50)))
+ ;; Top line should be approximately 50 chars
+ (let ((first-line (car (split-string result "\n" t))))
+ (should (<= (length first-line) 51))
+ (should (>= (length first-line) 48)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-block-banner-c-minimum-length ()
+ "Should work with minimum viable length."
+ (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "X" 10)))
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-match-p "X" result))))
+
+(ert-deftest test-block-banner-c-very-long-length ()
+ "Should handle very long length."
+ (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "Header" 200)))
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Top line should be very long
+ (let ((first-line (car (split-string result "\n" t))))
+ (should (> (length first-line) 100)))))
+
+(ert-deftest test-block-banner-c-unicode-decoration ()
+ "Should handle unicode decoration character."
+ (let ((result (test-block-banner-at-column 0 "/*" "*/" "✦" "Header" 70)))
+ (should (string-match-p "✦" result))))
+
+(ert-deftest test-block-banner-c-unicode-text ()
+ "Should handle unicode in text."
+ (let ((result (test-block-banner-at-column 0 "/*" "*/" "*" "Hello 👋 مرحبا café" 70)))
+ (should (string-match-p "👋" result))
+ (should (string-match-p "مرحبا" result))
+ (should (string-match-p "café" result))))
+
+(ert-deftest test-block-banner-c-very-long-text ()
+ "Should handle very long text."
+ (let* ((long-text (make-string 100 ?x))
+ (result (test-block-banner-at-column 0 "/*" "*/" "*" long-text 70)))
+ ;; Should still generate output
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Middle line should contain some of the text
+ (should (string-match-p "xxx" result))))
+
+(ert-deftest test-block-banner-c-max-indentation ()
+ "Should handle maximum practical indentation."
+ (let ((result (test-block-banner-at-column 60 "/*" "*/" "*" "Header" 100)))
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; First line should start with 60 spaces
+ (should (string-prefix-p (make-string 60 ?\s) result))))
+
+;;; Error Cases
+
+(ert-deftest test-block-banner-c-length-too-small ()
+ "Should error when length is too small."
+ (should-error
+ (test-block-banner-at-column 0 "/*" "*/" "*" "Header" 3)
+ :type 'error))
+
+(ert-deftest test-block-banner-c-negative-length ()
+ "Should error with negative length."
+ (should-error
+ (test-block-banner-at-column 0 "/*" "*/" "*" "Header" -10)
+ :type 'error))
+
+(ert-deftest test-block-banner-c-zero-length ()
+ "Should error with zero length."
+ (should-error
+ (test-block-banner-at-column 0 "/*" "*/" "*" "Header" 0)
+ :type 'error))
+
+(ert-deftest test-block-banner-c-nil-decoration ()
+ "Should error when decoration-char is nil."
+ (should-error
+ (test-block-banner-at-column 0 "/*" "*/" nil "Header" 70)
+ :type 'wrong-type-argument))
+
+(ert-deftest test-block-banner-c-nil-text ()
+ "Should error when text is nil."
+ (should-error
+ (test-block-banner-at-column 0 "/*" "*/" "*" nil 70)
+ :type 'wrong-type-argument))
+
+(ert-deftest test-block-banner-c-non-integer-length ()
+ "Should error when length is not an integer."
+ (should-error
+ (test-block-banner-at-column 0 "/*" "*/" "*" "Header" "not-a-number")
+ :type 'wrong-type-argument))
+
+;;; Alternative Block Comment Styles
+
+(ert-deftest test-block-banner-java-style ()
+ "Should work with Java-style block comments."
+ (let ((result (test-block-banner-at-column 0 "/**" "*/" "*" "JavaDoc Comment" 70)))
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-match-p "^/\\*\\*\\*" result))
+ (should (string-match-p "JavaDoc Comment" result))))
+
+(ert-deftest test-block-banner-js-style ()
+ "Should work with JavaScript-style block comments."
+ (let ((result (test-block-banner-at-column 2 "/*" "*/" "*" "Function Documentation" 70)))
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-prefix-p " /*" result))
+ (should (string-match-p "Function Documentation" result))))
+
+(provide 'test-custom-comments-comment-block-banner)
+;;; test-custom-comments-comment-block-banner.el ends here
diff --git a/tests/test-custom-comments-comment-box.el b/tests/test-custom-comments-comment-box.el
new file mode 100644
index 00000000..10b1a67d
--- /dev/null
+++ b/tests/test-custom-comments-comment-box.el
@@ -0,0 +1,241 @@
+;;; test-custom-comments-comment-box.el --- Tests for cj/comment-box -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/comment-box function from custom-comments.el
+;;
+;; This function generates a 3-line box comment:
+;; - Top border: comment-start + full decoration line
+;; - Text line: comment-start + decoration + spaces + text + spaces + decoration
+;; - Bottom border: comment-start + full decoration line
+;;
+;; The text is centered within the box with decoration characters on the sides.
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--comment-box)
+;; to avoid mocking user prompts. This follows our testing best practice
+;; of separating business logic from UI interaction.
+;;
+;; Cross-Language Testing Strategy:
+;; - Comprehensive testing in Emacs Lisp (our primary language)
+;; - Representative testing in Python and C (hash-based and C-style comments)
+;; - Function handles comment syntax generically, so testing 3 syntaxes
+;; proves cross-language compatibility
+;; - See test-custom-comments-delete-buffer-comments.el for detailed rationale
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-comments)
+
+;;; Test Helpers
+
+(defun test-comment-box-at-column (column-pos comment-start comment-end decoration-char text length)
+ "Test cj/--comment-box at COLUMN-POS indentation.
+Insert spaces to reach COLUMN-POS, then call cj/--comment-box with
+COMMENT-START, COMMENT-END, DECORATION-CHAR, TEXT, and LENGTH.
+Returns the buffer string for assertions."
+ (with-temp-buffer
+ (when (> column-pos 0)
+ (insert (make-string column-pos ?\s)))
+ (cj/--comment-box comment-start comment-end decoration-char text length)
+ (buffer-string)))
+
+;;; Emacs Lisp Tests (Primary Language - Comprehensive Coverage)
+
+;;; Normal Cases
+
+(ert-deftest test-comment-box-elisp-basic ()
+ "Should generate 3-line box in emacs-lisp style."
+ (let ((result (test-comment-box-at-column 0 ";;" "" "-" "Section Header" 70)))
+ ;; Should have 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; First line should start with ;; and have decoration
+ (should (string-match-p "^;; -" result))
+ ;; Middle line should contain text with side borders
+ (should (string-match-p ";; - .* Section Header .* - ;;" result))
+ ;; Should have top and bottom borders
+ (should (string-match-p "^;; -" result))))
+
+(ert-deftest test-comment-box-elisp-custom-decoration ()
+ "Should use custom decoration character."
+ (let ((result (test-comment-box-at-column 0 ";;" "" "*" "Header" 70)))
+ (should (string-match-p ";; \\*" result))
+ (should-not (string-match-p "-" result))))
+
+(ert-deftest test-comment-box-elisp-custom-text ()
+ "Should include custom text centered in box."
+ (let ((result (test-comment-box-at-column 0 ";;" "" "-" "Custom Text Here" 70)))
+ (should (string-match-p "Custom Text Here" result))))
+
+(ert-deftest test-comment-box-elisp-empty-text ()
+ "Should handle empty text string."
+ (let ((result (test-comment-box-at-column 0 ";;" "" "-" "" 70)))
+ ;; Should still generate 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Should have side borders
+ (should (string-match-p "- .*-" result))))
+
+(ert-deftest test-comment-box-elisp-at-column-0 ()
+ "Should work at column 0."
+ (let ((result (test-comment-box-at-column 0 ";;" "" "-" "Header" 70)))
+ ;; First character should be semicolon
+ (should (string-prefix-p ";;" result))))
+
+(ert-deftest test-comment-box-elisp-indented ()
+ "Should work when indented."
+ (let ((result (test-comment-box-at-column 4 ";;" "" "-" "Header" 70)))
+ ;; First line should start with spaces
+ (should (string-prefix-p " ;;" result))
+ ;; Other lines should be indented
+ (let ((lines (split-string result "\n" t)))
+ (should (string-prefix-p " " (nth 1 lines)))
+ (should (string-prefix-p " " (nth 2 lines))))))
+
+(ert-deftest test-comment-box-elisp-short-text ()
+ "Should center short text properly."
+ (let ((result (test-comment-box-at-column 0 ";;" "" "-" "X" 70)))
+ ;; Should have 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Text should be present and centered
+ (should (string-match-p "- .* X .* -" result))))
+
+(ert-deftest test-comment-box-elisp-long-text ()
+ "Should handle longer text."
+ (let ((result (test-comment-box-at-column 0 ";;" "" "-" "This is a longer header text" 70)))
+ ;; Should have 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Text should be present
+ (should (string-match-p "This is a longer header text" result))))
+
+;;; Boundary Cases
+
+(ert-deftest test-comment-box-elisp-minimum-length ()
+ "Should work with minimum viable length."
+ (let ((result (test-comment-box-at-column 0 ";;" "" "-" "X" 15)))
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-match-p "X" result))))
+
+(ert-deftest test-comment-box-elisp-very-long-length ()
+ "Should handle very long length."
+ (let ((result (test-comment-box-at-column 0 ";;" "" "-" "Header" 200)))
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Border lines should be very long
+ (let ((first-line (car (split-string result "\n" t))))
+ (should (> (length first-line) 100)))))
+
+(ert-deftest test-comment-box-elisp-unicode-decoration ()
+ "Should handle unicode decoration character."
+ (let ((result (test-comment-box-at-column 0 ";;" "" "═" "Header" 70)))
+ (should (string-match-p "═" result))))
+
+(ert-deftest test-comment-box-elisp-unicode-text ()
+ "Should handle unicode in text."
+ (let ((result (test-comment-box-at-column 0 ";;" "" "-" "Hello 👋 مرحبا café" 70)))
+ (should (string-match-p "👋" result))
+ (should (string-match-p "مرحبا" result))
+ (should (string-match-p "café" result))))
+
+(ert-deftest test-comment-box-elisp-very-long-text ()
+ "Should handle very long text."
+ (let* ((long-text (make-string 100 ?x))
+ (result (test-comment-box-at-column 0 ";;" "" "-" long-text 70)))
+ ;; Should still generate output
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Middle line should contain some of the text
+ (should (string-match-p "xxx" result))))
+
+(ert-deftest test-comment-box-elisp-comment-end-symmetric ()
+ "Should use symmetric comment syntax when comment-end is empty."
+ (let ((result (test-comment-box-at-column 0 ";;" "" "-" "Header" 70)))
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Should use ;; on both sides for symmetry
+ (should (string-match-p ";;.*;;$" result))))
+
+(ert-deftest test-comment-box-elisp-max-indentation ()
+ "Should handle maximum practical indentation."
+ (let ((result (test-comment-box-at-column 60 ";;" "" "-" "Header" 100)))
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; First line should start with 60 spaces
+ (should (string-prefix-p (make-string 60 ?\s) result))))
+
+(ert-deftest test-comment-box-elisp-text-centering-even ()
+ "Should center text properly with even length."
+ (let ((result (test-comment-box-at-column 0 ";;" "" "-" "EVEN" 70)))
+ ;; Text should be centered (roughly equal padding on both sides)
+ (should (string-match-p "- .* EVEN .* -" result))))
+
+(ert-deftest test-comment-box-elisp-text-centering-odd ()
+ "Should center text properly with odd length."
+ (let ((result (test-comment-box-at-column 0 ";;" "" "-" "ODD" 70)))
+ ;; Text should be centered (roughly equal padding on both sides)
+ (should (string-match-p "- .* ODD .* -" result))))
+
+;;; Error Cases
+
+(ert-deftest test-comment-box-elisp-length-too-small ()
+ "Should error when length is too small."
+ (should-error
+ (test-comment-box-at-column 0 ";;" "" "-" "Header" 5)
+ :type 'error))
+
+(ert-deftest test-comment-box-elisp-negative-length ()
+ "Should error with negative length."
+ (should-error
+ (test-comment-box-at-column 0 ";;" "" "-" "Header" -10)
+ :type 'error))
+
+(ert-deftest test-comment-box-elisp-zero-length ()
+ "Should error with zero length."
+ (should-error
+ (test-comment-box-at-column 0 ";;" "" "-" "Header" 0)
+ :type 'error))
+
+(ert-deftest test-comment-box-elisp-nil-decoration ()
+ "Should error when decoration-char is nil."
+ (should-error
+ (test-comment-box-at-column 0 ";;" "" nil "Header" 70)
+ :type 'wrong-type-argument))
+
+(ert-deftest test-comment-box-elisp-non-integer-length ()
+ "Should error when length is not an integer."
+ (should-error
+ (test-comment-box-at-column 0 ";;" "" "-" "Header" "not-a-number")
+ :type 'wrong-type-argument))
+
+;;; Python Tests (Hash-based comments)
+
+(ert-deftest test-comment-box-python-basic ()
+ "Should generate box with Python comment syntax."
+ (let ((result (test-comment-box-at-column 0 "#" "" "-" "Section" 70)))
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-match-p "^# -" result))
+ (should (string-match-p "Section" result))))
+
+(ert-deftest test-comment-box-python-indented ()
+ "Should handle indented Python comments."
+ (let ((result (test-comment-box-at-column 4 "#" "" "#" "Function Section" 70)))
+ (should (string-prefix-p " #" result))
+ (should (string-match-p "Function Section" result))))
+
+;;; C Tests (C-style comments)
+
+(ert-deftest test-comment-box-c-block-comments ()
+ "Should generate box with C block comment syntax."
+ (let ((result (test-comment-box-at-column 0 "/*" "*/" "-" "Section" 70)))
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-match-p "^/\\* -" result))
+ (should (string-match-p "Section" result))
+ ;; Should include comment-end
+ (should (string-match-p "\\*/" result))))
+
+(provide 'test-custom-comments-comment-box)
+;;; test-custom-comments-comment-box.el ends here
diff --git a/tests/test-custom-comments-comment-heavy-box.el b/tests/test-custom-comments-comment-heavy-box.el
new file mode 100644
index 00000000..30289625
--- /dev/null
+++ b/tests/test-custom-comments-comment-heavy-box.el
@@ -0,0 +1,251 @@
+;;; test-custom-comments-comment-heavy-box.el --- Tests for cj/comment-heavy-box -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/comment-heavy-box function from custom-comments.el
+;;
+;; This function generates a 5-line heavy box comment:
+;; - Top border: comment-start + full decoration line
+;; - Empty line: decoration char + spaces + decoration char
+;; - Centered text: decoration char + spaces + text + spaces + decoration char
+;; - Empty line: decoration char + spaces + decoration char
+;; - Bottom border: comment-start + full decoration line
+;;
+;; The text is centered within the box with padding on both sides.
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--comment-heavy-box)
+;; to avoid mocking user prompts. This follows our testing best practice
+;; of separating business logic from UI interaction.
+;;
+;; Cross-Language Testing Strategy:
+;; - Comprehensive testing in Emacs Lisp (our primary language)
+;; - Representative testing in Python and C (hash-based and C-style comments)
+;; - Function handles comment syntax generically, so testing 3 syntaxes
+;; proves cross-language compatibility
+;; - See test-custom-comments-delete-buffer-comments.el for detailed rationale
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-comments)
+
+;;; Test Helpers
+
+(defun test-heavy-box-at-column (column-pos comment-start comment-end decoration-char text length)
+ "Test cj/--comment-heavy-box at COLUMN-POS indentation.
+Insert spaces to reach COLUMN-POS, then call cj/--comment-heavy-box with
+COMMENT-START, COMMENT-END, DECORATION-CHAR, TEXT, and LENGTH.
+Returns the buffer string for assertions."
+ (with-temp-buffer
+ (when (> column-pos 0)
+ (insert (make-string column-pos ?\s)))
+ (cj/--comment-heavy-box comment-start comment-end decoration-char text length)
+ (buffer-string)))
+
+;;; Emacs Lisp Tests (Primary Language - Comprehensive Coverage)
+
+;;; Normal Cases
+
+(ert-deftest test-heavy-box-elisp-basic ()
+ "Should generate 5-line heavy box in emacs-lisp style."
+ (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "Section Header" 70)))
+ ;; Should have 5 lines
+ (should (= 5 (length (split-string result "\n" t))))
+ ;; First line should start with ;; and have decoration
+ (should (string-match-p "^;; \\*" result))
+ ;; Middle line should contain centered text
+ (should (string-match-p "Section Header" result))
+ ;; Should have side borders
+ (should (string-match-p "^\\*.*\\*$" result))))
+
+(ert-deftest test-heavy-box-elisp-custom-decoration ()
+ "Should use custom decoration character."
+ (let ((result (test-heavy-box-at-column 0 ";;" "" "#" "Header" 70)))
+ (should (string-match-p ";; #" result))
+ (should-not (string-match-p "\\*" result))))
+
+(ert-deftest test-heavy-box-elisp-custom-text ()
+ "Should include custom text centered in box."
+ (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "Custom Text Here" 70)))
+ (should (string-match-p "Custom Text Here" result))))
+
+(ert-deftest test-heavy-box-elisp-empty-text ()
+ "Should handle empty text string."
+ (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "" 70)))
+ ;; Should still generate 5 lines
+ (should (= 5 (length (split-string result "\n" t))))
+ ;; Middle line should just have side borders and spaces
+ (should (string-match-p "^\\*.*\\*$" result))))
+
+(ert-deftest test-heavy-box-elisp-at-column-0 ()
+ "Should work at column 0."
+ (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "Header" 70)))
+ ;; First character should be semicolon
+ (should (string-prefix-p ";;" result))))
+
+(ert-deftest test-heavy-box-elisp-indented ()
+ "Should work when indented."
+ (let ((result (test-heavy-box-at-column 4 ";;" "" "*" "Header" 70)))
+ ;; First line should start with spaces
+ (should (string-prefix-p " ;;" result))
+ ;; Other lines should be indented
+ (let ((lines (split-string result "\n" t)))
+ (should (string-prefix-p " " (nth 1 lines)))
+ (should (string-prefix-p " " (nth 2 lines))))))
+
+(ert-deftest test-heavy-box-elisp-short-text ()
+ "Should center short text properly."
+ (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "X" 70)))
+ ;; Should have 5 lines
+ (should (= 5 (length (split-string result "\n" t))))
+ ;; Text should be present and centered
+ (should (string-match-p "\\* .* X .* \\*" result))))
+
+(ert-deftest test-heavy-box-elisp-long-text ()
+ "Should handle longer text."
+ (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "This is a longer header text" 70)))
+ ;; Should have 5 lines
+ (should (= 5 (length (split-string result "\n" t))))
+ ;; Text should be present
+ (should (string-match-p "This is a longer header text" result))))
+
+;;; Boundary Cases
+
+(ert-deftest test-heavy-box-elisp-minimum-length ()
+ "Should work with minimum viable length."
+ ;; Minimum for a box: comment + spaces + borders + minimal content
+ (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "X" 15)))
+ (should (= 5 (length (split-string result "\n" t))))
+ (should (string-match-p "X" result))))
+
+(ert-deftest test-heavy-box-elisp-very-long-length ()
+ "Should handle very long length."
+ (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "Header" 200)))
+ (should (= 5 (length (split-string result "\n" t))))
+ ;; Border lines should be very long
+ (let ((first-line (car (split-string result "\n" t))))
+ (should (> (length first-line) 100)))))
+
+(ert-deftest test-heavy-box-elisp-unicode-decoration ()
+ "Should handle unicode decoration character."
+ (let ((result (test-heavy-box-at-column 0 ";;" "" "═" "Header" 70)))
+ (should (string-match-p "═" result))))
+
+(ert-deftest test-heavy-box-elisp-unicode-text ()
+ "Should handle unicode in text."
+ (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "Hello 👋 مرحبا café" 70)))
+ (should (string-match-p "👋" result))
+ (should (string-match-p "مرحبا" result))
+ (should (string-match-p "café" result))))
+
+(ert-deftest test-heavy-box-elisp-very-long-text ()
+ "Should handle very long text."
+ (let* ((long-text (make-string 100 ?x))
+ (result (test-heavy-box-at-column 0 ";;" "" "*" long-text 70)))
+ ;; Should still generate output
+ (should (= 5 (length (split-string result "\n" t))))
+ ;; Middle line should contain some of the text
+ (should (string-match-p "xxx" result))))
+
+(ert-deftest test-heavy-box-elisp-comment-end-empty ()
+ "Should handle empty comment-end by using symmetric comment syntax."
+ (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "Header" 70)))
+ (should (= 5 (length (split-string result "\n" t))))
+ ;; When comment-end is empty, function uses comment-char for symmetry
+ ;; So border lines will have ";; ... ;;" for visual balance
+ (should (string-match-p ";;.*;;$" result))))
+
+(ert-deftest test-heavy-box-elisp-max-indentation ()
+ "Should handle maximum practical indentation."
+ (let ((result (test-heavy-box-at-column 60 ";;" "" "*" "Header" 100)))
+ (should (= 5 (length (split-string result "\n" t))))
+ ;; First line should start with 60 spaces
+ (should (string-prefix-p (make-string 60 ?\s) result))))
+
+(ert-deftest test-heavy-box-elisp-text-centering-even ()
+ "Should center text properly with even length."
+ (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "EVEN" 70)))
+ ;; Text should be centered (roughly equal padding on both sides)
+ (should (string-match-p "\\* .* EVEN .* \\*" result))))
+
+(ert-deftest test-heavy-box-elisp-text-centering-odd ()
+ "Should center text properly with odd length."
+ (let ((result (test-heavy-box-at-column 0 ";;" "" "*" "ODD" 70)))
+ ;; Text should be centered (roughly equal padding on both sides)
+ (should (string-match-p "\\* .* ODD .* \\*" result))))
+
+;;; Error Cases
+
+(ert-deftest test-heavy-box-elisp-length-too-small ()
+ "Should error when length is too small."
+ (should-error
+ (test-heavy-box-at-column 0 ";;" "" "*" "Header" 5)
+ :type 'error))
+
+(ert-deftest test-heavy-box-elisp-negative-length ()
+ "Should error with negative length."
+ (should-error
+ (test-heavy-box-at-column 0 ";;" "" "*" "Header" -10)
+ :type 'error))
+
+(ert-deftest test-heavy-box-elisp-zero-length ()
+ "Should error with zero length."
+ (should-error
+ (test-heavy-box-at-column 0 ";;" "" "*" "Header" 0)
+ :type 'error))
+
+(ert-deftest test-heavy-box-elisp-nil-decoration ()
+ "Should error when decoration-char is nil."
+ (should-error
+ (test-heavy-box-at-column 0 ";;" "" nil "Header" 70)
+ :type 'wrong-type-argument))
+
+(ert-deftest test-heavy-box-elisp-nil-text ()
+ "Should error when text is nil."
+ (should-error
+ (test-heavy-box-at-column 0 ";;" "" "*" nil 70)
+ :type 'wrong-type-argument))
+
+(ert-deftest test-heavy-box-elisp-non-integer-length ()
+ "Should error when length is not an integer."
+ (should-error
+ (test-heavy-box-at-column 0 ";;" "" "*" "Header" "not-a-number")
+ :type 'wrong-type-argument))
+
+;;; Python Tests (Hash-based comments)
+
+(ert-deftest test-heavy-box-python-basic ()
+ "Should generate heavy box with Python comment syntax."
+ (let ((result (test-heavy-box-at-column 0 "#" "" "*" "Section" 70)))
+ (should (= 5 (length (split-string result "\n" t))))
+ (should (string-match-p "^# \\*" result))
+ (should (string-match-p "Section" result))))
+
+(ert-deftest test-heavy-box-python-indented ()
+ "Should handle indented Python comments."
+ (let ((result (test-heavy-box-at-column 4 "#" "" "#" "Function Section" 70)))
+ (should (string-prefix-p " #" result))
+ (should (string-match-p "Function Section" result))))
+
+;;; C Tests (C-style comments)
+
+(ert-deftest test-heavy-box-c-block-comments ()
+ "Should generate heavy box with C block comment syntax."
+ (let ((result (test-heavy-box-at-column 0 "/*" "*/" "*" "Section" 70)))
+ (should (= 5 (length (split-string result "\n" t))))
+ (should (string-match-p "^/\\* \\*" result))
+ (should (string-match-p "Section" result))
+ ;; Should include comment-end
+ (should (string-match-p "\\*/" result))))
+
+(provide 'test-custom-comments-comment-heavy-box)
+;;; test-custom-comments-comment-heavy-box.el ends here
diff --git a/tests/test-custom-comments-comment-inline-border.el b/tests/test-custom-comments-comment-inline-border.el
new file mode 100644
index 00000000..ca2bef06
--- /dev/null
+++ b/tests/test-custom-comments-comment-inline-border.el
@@ -0,0 +1,235 @@
+;;; test-custom-comments-comment-inline-border.el --- Tests for cj/comment-inline-border -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/comment-inline-border function from custom-comments.el
+;;
+;; This function generates a single-line centered comment with decoration borders:
+;; Format: comment-start + decoration + space + text + space + decoration + comment-end
+;; Example: ";; ======= Section Header ======="
+;;
+;; The text is centered with decoration characters on both sides. When text has
+;; odd length, the right side gets one less decoration character.
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--comment-inline-border)
+;; to avoid mocking user prompts. This follows our testing best practice
+;; of separating business logic from UI interaction.
+;;
+;; Cross-Language Testing Strategy:
+;; - Comprehensive testing in Emacs Lisp (our primary language)
+;; - Representative testing in Python and C (hash-based and C-style comments)
+;; - Function handles comment syntax generically, so testing 3 syntaxes
+;; proves cross-language compatibility
+;; - See test-custom-comments-delete-buffer-comments.el for detailed rationale
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-comments)
+
+;;; Test Helpers
+
+(defun test-inline-border-at-column (column-pos comment-start comment-end decoration-char text length)
+ "Test cj/--comment-inline-border at COLUMN-POS indentation.
+Insert spaces to reach COLUMN-POS, then call cj/--comment-inline-border with
+COMMENT-START, COMMENT-END, DECORATION-CHAR, TEXT, and LENGTH.
+Returns the buffer string for assertions."
+ (with-temp-buffer
+ (when (> column-pos 0)
+ (insert (make-string column-pos ?\s)))
+ (cj/--comment-inline-border comment-start comment-end decoration-char text length)
+ (buffer-string)))
+
+;;; Emacs Lisp Tests (Primary Language - Comprehensive Coverage)
+
+;;; Normal Cases
+
+(ert-deftest test-inline-border-elisp-basic ()
+ "Should generate single-line centered comment in emacs-lisp style."
+ (let ((result (test-inline-border-at-column 0 ";;" "" "=" "Section Header" 70)))
+ ;; Should be single line
+ (should (= 1 (length (split-string result "\n" t))))
+ ;; Should start with ;;
+ (should (string-match-p "^;; =" result))
+ ;; Should contain text
+ (should (string-match-p "Section Header" result))
+ ;; Should have decoration on both sides
+ (should (string-match-p "= Section Header =" result))))
+
+(ert-deftest test-inline-border-elisp-custom-decoration ()
+ "Should use custom decoration character."
+ (let ((result (test-inline-border-at-column 0 ";;" "" "#" "Header" 70)))
+ (should (string-match-p ";; #" result))
+ (should (string-match-p "# Header #" result))
+ (should-not (string-match-p "=" result))))
+
+(ert-deftest test-inline-border-elisp-custom-text ()
+ "Should include custom text centered."
+ (let ((result (test-inline-border-at-column 0 ";;" "" "=" "Custom Text Here" 70)))
+ (should (string-match-p "Custom Text Here" result))))
+
+(ert-deftest test-inline-border-elisp-empty-text ()
+ "Should handle empty text string."
+ (let ((result (test-inline-border-at-column 0 ";;" "" "=" "" 70)))
+ ;; Should still generate output with decoration
+ (should (string-match-p ";; =" result))
+ ;; Should not have extra spaces where text would be
+ (should-not (string-match-p " " result))))
+
+(ert-deftest test-inline-border-elisp-at-column-0 ()
+ "Should work at column 0."
+ (let ((result (test-inline-border-at-column 0 ";;" "" "=" "Header" 70)))
+ ;; First character should be semicolon
+ (should (string-prefix-p ";;" result))))
+
+(ert-deftest test-inline-border-elisp-indented ()
+ "Should work when indented."
+ (let ((result (test-inline-border-at-column 4 ";;" "" "=" "Header" 70)))
+ ;; Result should start with spaces
+ (should (string-prefix-p " ;;" result))))
+
+(ert-deftest test-inline-border-elisp-short-text ()
+ "Should center short text properly."
+ (let ((result (test-inline-border-at-column 0 ";;" "" "=" "X" 70)))
+ (should (string-match-p "X" result))
+ ;; Should have decoration on both sides
+ (should (string-match-p "= X =" result))))
+
+(ert-deftest test-inline-border-elisp-custom-length ()
+ "Should respect custom length."
+ (let ((result (test-inline-border-at-column 0 ";;" "" "=" "Header" 50)))
+ ;; Line should be approximately 50 chars
+ (let ((line (car (split-string result "\n" t))))
+ (should (<= (length line) 51))
+ (should (>= (length line) 48)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-inline-border-elisp-minimum-length ()
+ "Should work with minimum viable length."
+ ;; Minimum: 2 (;;) + 1 (space) + 1 (space) + 2 (min decoration each side) = 6
+ (let ((result (test-inline-border-at-column 0 ";;" "" "=" "" 10)))
+ (should (string-match-p ";" result))))
+
+(ert-deftest test-inline-border-elisp-text-centering-even ()
+ "Should center text properly with even length."
+ (let ((result (test-inline-border-at-column 0 ";;" "" "=" "EVEN" 70)))
+ ;; Text should be centered with roughly equal decoration
+ (should (string-match-p "= EVEN =" result))))
+
+(ert-deftest test-inline-border-elisp-text-centering-odd ()
+ "Should center text properly with odd length."
+ (let ((result (test-inline-border-at-column 0 ";;" "" "=" "ODD" 70)))
+ ;; Text should be centered (right side has one less due to odd length)
+ (should (string-match-p "= ODD =" result))))
+
+(ert-deftest test-inline-border-elisp-very-long-text ()
+ "Should handle text that fills most of the line."
+ (let* ((long-text (make-string 50 ?x))
+ (result (test-inline-border-at-column 0 ";;" "" "=" long-text 70)))
+ ;; Should still have decoration
+ (should (string-match-p "=" result))
+ ;; Text should be present
+ (should (string-match-p "xxx" result))))
+
+(ert-deftest test-inline-border-elisp-unicode-decoration ()
+ "Should handle unicode decoration character."
+ (let ((result (test-inline-border-at-column 0 ";;" "" "─" "Header" 70)))
+ (should (string-match-p "─" result))))
+
+(ert-deftest test-inline-border-elisp-unicode-text ()
+ "Should handle unicode in text."
+ (let ((result (test-inline-border-at-column 0 ";;" "" "=" "Hello 👋 café" 70)))
+ (should (string-match-p "👋" result))
+ (should (string-match-p "café" result))))
+
+(ert-deftest test-inline-border-elisp-comment-end-empty ()
+ "Should handle empty comment-end correctly."
+ (let ((result (test-inline-border-at-column 0 ";;" "" "=" "Header" 70)))
+ ;; Line should not have trailing comment-end
+ (should-not (string-match-p ";;$" result))))
+
+(ert-deftest test-inline-border-elisp-max-indentation ()
+ "Should handle maximum practical indentation."
+ (let ((result (test-inline-border-at-column 60 ";;" "" "=" "H" 100)))
+ (should (string-prefix-p (make-string 60 ?\s) result))))
+
+(ert-deftest test-inline-border-elisp-minimum-decoration-each-side ()
+ "Should have at least 2 decoration chars on each side."
+ (let ((result (test-inline-border-at-column 0 ";;" "" "=" "Test" 20)))
+ ;; Should have at least == on each side
+ (should (string-match-p "== Test ==" result))))
+
+;;; Error Cases
+
+(ert-deftest test-inline-border-elisp-length-too-small ()
+ "Should error when length is too small for text."
+ (should-error
+ (test-inline-border-at-column 0 ";;" "" "=" "Very Long Header Text" 20)
+ :type 'error))
+
+(ert-deftest test-inline-border-elisp-negative-length ()
+ "Should error with negative length."
+ (should-error
+ (test-inline-border-at-column 0 ";;" "" "=" "Header" -10)
+ :type 'error))
+
+(ert-deftest test-inline-border-elisp-zero-length ()
+ "Should error with zero length."
+ (should-error
+ (test-inline-border-at-column 0 ";;" "" "=" "Header" 0)
+ :type 'error))
+
+(ert-deftest test-inline-border-elisp-nil-decoration ()
+ "Should error when decoration-char is nil."
+ (should-error
+ (test-inline-border-at-column 0 ";;" "" nil "Header" 70)
+ :type 'wrong-type-argument))
+
+(ert-deftest test-inline-border-elisp-non-integer-length ()
+ "Should error when length is not an integer."
+ (should-error
+ (test-inline-border-at-column 0 ";;" "" "=" "Header" "not-a-number")
+ :type 'wrong-type-argument))
+
+;;; Python Tests (Hash-based comments)
+
+(ert-deftest test-inline-border-python-basic ()
+ "Should generate inline border with Python comment syntax."
+ (let ((result (test-inline-border-at-column 0 "#" "" "=" "Section" 70)))
+ (should (string-match-p "^# =" result))
+ (should (string-match-p "Section" result))))
+
+(ert-deftest test-inline-border-python-indented ()
+ "Should handle indented Python comments."
+ (let ((result (test-inline-border-at-column 4 "#" "" "-" "Function Section" 70)))
+ (should (string-prefix-p " #" result))
+ (should (string-match-p "Function Section" result))))
+
+;;; C Tests (C-style comments)
+
+(ert-deftest test-inline-border-c-block-comments ()
+ "Should generate inline border with C block comment syntax."
+ (let ((result (test-inline-border-at-column 0 "/*" "*/" "=" "Section" 70)))
+ (should (string-match-p "^/\\* =" result))
+ (should (string-match-p "Section" result))
+ ;; Should include comment-end
+ (should (string-match-p "\\*/$" result))))
+
+(ert-deftest test-inline-border-c-line-comments ()
+ "Should generate inline border with C line comment syntax."
+ (let ((result (test-inline-border-at-column 0 "//" "" "-" "Header" 70)))
+ (should (string-match-p "^// -" result))
+ (should (string-match-p "Header" result))))
+
+(provide 'test-custom-comments-comment-inline-border)
+;;; test-custom-comments-comment-inline-border.el ends here
diff --git a/tests/test-custom-comments-comment-padded-divider.el b/tests/test-custom-comments-comment-padded-divider.el
new file mode 100644
index 00000000..702a4c67
--- /dev/null
+++ b/tests/test-custom-comments-comment-padded-divider.el
@@ -0,0 +1,250 @@
+;;; test-custom-comments-comment-padded-divider.el --- Tests for cj/comment-padded-divider -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/comment-padded-divider function from custom-comments.el
+;;
+;; This function generates a padded 3-line comment divider banner:
+;; - Top line: comment-start + decoration chars
+;; - Middle line: comment-start + padding spaces + text
+;; - Bottom line: comment-start + decoration chars
+;;
+;; The key difference from simple-divider is the PADDING parameter which
+;; adds spaces before the text to create visual indentation.
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--comment-padded-divider)
+;; to avoid mocking user prompts. This follows our testing best practice
+;; of separating business logic from UI interaction.
+;;
+;; Cross-Language Testing Strategy:
+;; - Comprehensive testing in Emacs Lisp (our primary language)
+;; - Representative testing in Python and C (hash-based and C-style comments)
+;; - Function handles comment syntax generically, so testing 3 syntaxes
+;; proves cross-language compatibility
+;; - See test-custom-comments-delete-buffer-comments.el for detailed rationale
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-comments)
+
+;;; Test Helpers
+
+(defun test-padded-divider-at-column (column-pos comment-start comment-end decoration-char text length padding)
+ "Test cj/--comment-padded-divider at COLUMN-POS indentation.
+Insert spaces to reach COLUMN-POS, then call cj/--comment-padded-divider with
+COMMENT-START, COMMENT-END, DECORATION-CHAR, TEXT, LENGTH, and PADDING.
+Returns the buffer string for assertions."
+ (with-temp-buffer
+ (when (> column-pos 0)
+ (insert (make-string column-pos ?\s)))
+ (cj/--comment-padded-divider comment-start comment-end decoration-char text length padding)
+ (buffer-string)))
+
+;;; Emacs Lisp Tests (Primary Language - Comprehensive Coverage)
+
+;;; Normal Cases
+
+(ert-deftest test-padded-divider-elisp-basic ()
+ "Should generate padded 3-line divider in emacs-lisp style."
+ (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "Section Header" 70 2)))
+ ;; Should have 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; First line should start with ;; and have decoration
+ (should (string-match-p "^;; =" result))
+ ;; Middle line should contain text with padding
+ (should (string-match-p ";; Section Header" result))))
+
+(ert-deftest test-padded-divider-elisp-custom-padding ()
+ "Should respect custom padding value."
+ (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "Header" 70 4)))
+ ;; Middle line should have 4 spaces before text
+ (should (string-match-p ";; Header" result))))
+
+(ert-deftest test-padded-divider-elisp-zero-padding ()
+ "Should work with zero padding."
+ (let ((result (test-padded-divider-at-column 0 ";;" "" "-" "Header" 70 0)))
+ ;; Middle line should have text immediately after comment-start + space
+ (should (string-match-p "^;; Header$" result))))
+
+(ert-deftest test-padded-divider-elisp-large-padding ()
+ "Should work with large padding value."
+ (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "Text" 70 10)))
+ ;; Middle line should have 10 spaces before text
+ (should (string-match-p ";; Text" result))))
+
+(ert-deftest test-padded-divider-elisp-custom-decoration ()
+ "Should use custom decoration character."
+ (let ((result (test-padded-divider-at-column 0 ";;" "" "*" "Header" 70 2)))
+ (should (string-match-p ";; \\*" result))
+ (should-not (string-match-p ";; =" result))))
+
+(ert-deftest test-padded-divider-elisp-custom-text ()
+ "Should include custom text in middle line."
+ (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "Custom Text Here" 70 2)))
+ (should (string-match-p "Custom Text Here" result))))
+
+(ert-deftest test-padded-divider-elisp-empty-text ()
+ "Should handle empty text string."
+ (let ((result (test-padded-divider-at-column 0 ";;" "" "-" "" 70 2)))
+ ;; Should still generate 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Middle line should just be comment-start + padding
+ (should (string-match-p "^;; *\n" result))))
+
+(ert-deftest test-padded-divider-elisp-at-column-0 ()
+ "Should work at column 0."
+ (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "Header" 70 2)))
+ ;; First character should be semicolon
+ (should (string-prefix-p ";;" result))))
+
+(ert-deftest test-padded-divider-elisp-indented ()
+ "Should work when indented."
+ (let ((result (test-padded-divider-at-column 4 ";;" "" "=" "Header" 70 2)))
+ ;; Result should start with spaces
+ (should (string-prefix-p " ;;" result))
+ ;; All lines should be indented
+ (dolist (line (split-string result "\n" t))
+ (should (string-prefix-p " ;;" line)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-padded-divider-elisp-minimum-length ()
+ "Should work with minimum viable length at column 0."
+ ;; Minimum: 2 (;;) + 1 (space) + 1 (space) + 3 (dashes) = 7
+ (let ((result (test-padded-divider-at-column 0 ";;" "" "-" "" 7 0)))
+ (should (= 3 (length (split-string result "\n" t))))))
+
+(ert-deftest test-padded-divider-elisp-very-long-length ()
+ "Should handle very long length."
+ (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "Header" 200 2)))
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Decoration lines should be very long
+ (let ((first-line (car (split-string result "\n" t))))
+ (should (> (length first-line) 100)))))
+
+(ert-deftest test-padded-divider-elisp-padding-larger-than-length ()
+ "Should handle padding that exceeds reasonable bounds."
+ ;; This tests behavior when padding is very large relative to length
+ (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "X" 70 50)))
+ ;; Should still generate output (text may extend beyond decoration)
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-match-p "X" result))))
+
+(ert-deftest test-padded-divider-elisp-unicode-decoration ()
+ "Should handle unicode decoration character."
+ (let ((result (test-padded-divider-at-column 0 ";;" "" "─" "Header" 70 2)))
+ (should (string-match-p "─" result))))
+
+(ert-deftest test-padded-divider-elisp-unicode-text ()
+ "Should handle unicode in text."
+ (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "Hello 👋 مرحبا café" 70 2)))
+ (should (string-match-p "👋" result))
+ (should (string-match-p "مرحبا" result))
+ (should (string-match-p "café" result))))
+
+(ert-deftest test-padded-divider-elisp-very-long-text ()
+ "Should handle very long text."
+ (let* ((long-text (make-string 100 ?x))
+ (result (test-padded-divider-at-column 0 ";;" "" "=" long-text 70 2)))
+ ;; Should still generate output
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Middle line should contain some of the text
+ (should (string-match-p "xxx" result))))
+
+(ert-deftest test-padded-divider-elisp-comment-end-empty ()
+ "Should handle empty comment-end correctly."
+ (let ((result (test-padded-divider-at-column 0 ";;" "" "=" "Header" 70 2)))
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Lines should not have trailing comment-end
+ (should-not (string-match-p ";;.*;;$" result))))
+
+(ert-deftest test-padded-divider-elisp-max-indentation ()
+ "Should handle maximum practical indentation."
+ (let ((result (test-padded-divider-at-column 60 ";;" "" "=" "Header" 100 2)))
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; All lines should start with 60 spaces
+ (dolist (line (split-string result "\n" t))
+ (should (string-prefix-p (make-string 60 ?\s) line)))))
+
+;;; Error Cases
+
+(ert-deftest test-padded-divider-elisp-negative-padding ()
+ "Should error with negative padding."
+ (should-error
+ (test-padded-divider-at-column 0 ";;" "" "=" "Header" 70 -5)
+ :type 'error))
+
+(ert-deftest test-padded-divider-elisp-negative-length ()
+ "Should error with negative length."
+ (should-error
+ (test-padded-divider-at-column 0 ";;" "" "=" "Header" -10 2)
+ :type 'error))
+
+(ert-deftest test-padded-divider-elisp-zero-length ()
+ "Should error with zero length."
+ (should-error
+ (test-padded-divider-at-column 0 ";;" "" "=" "Header" 0 2)
+ :type 'error))
+
+(ert-deftest test-padded-divider-elisp-nil-decoration ()
+ "Should error when decoration-char is nil."
+ (should-error
+ (test-padded-divider-at-column 0 ";;" "" nil "Header" 70 2)
+ :type 'wrong-type-argument))
+
+(ert-deftest test-padded-divider-elisp-nil-text ()
+ "Should error when text is nil."
+ (should-error
+ (test-padded-divider-at-column 0 ";;" "" "=" nil 70 2)
+ :type 'wrong-type-argument))
+
+(ert-deftest test-padded-divider-elisp-non-integer-length ()
+ "Should error when length is not an integer."
+ (should-error
+ (test-padded-divider-at-column 0 ";;" "" "=" "Header" "not-a-number" 2)
+ :type 'wrong-type-argument))
+
+(ert-deftest test-padded-divider-elisp-non-integer-padding ()
+ "Should error when padding is not an integer."
+ (should-error
+ (test-padded-divider-at-column 0 ";;" "" "=" "Header" 70 "not-a-number")
+ :type 'wrong-type-argument))
+
+;;; Python Tests (Hash-based comments)
+
+(ert-deftest test-padded-divider-python-basic ()
+ "Should generate padded divider with Python comment syntax."
+ (let ((result (test-padded-divider-at-column 0 "#" "" "=" "Section" 70 2)))
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-match-p "^# =" result))
+ (should (string-match-p "# Section" result))))
+
+(ert-deftest test-padded-divider-python-indented ()
+ "Should handle indented Python comments with padding."
+ (let ((result (test-padded-divider-at-column 4 "#" "" "-" "Function Section" 70 4)))
+ (should (string-prefix-p " #" result))
+ (should (string-match-p "Function Section" result))))
+
+;;; C Tests (C-style comments)
+
+(ert-deftest test-padded-divider-c-block-comments ()
+ "Should generate padded divider with C block comment syntax."
+ (let ((result (test-padded-divider-at-column 0 "/*" "*/" "=" "Section" 70 2)))
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-match-p "^/\\* =" result))
+ (should (string-match-p "/\\* Section" result))
+ ;; Should include comment-end
+ (should (string-match-p "\\*/" result))))
+
+(provide 'test-custom-comments-comment-padded-divider)
+;;; test-custom-comments-comment-padded-divider.el ends here
diff --git a/tests/test-custom-comments-comment-reformat.el b/tests/test-custom-comments-comment-reformat.el
new file mode 100644
index 00000000..83248aee
--- /dev/null
+++ b/tests/test-custom-comments-comment-reformat.el
@@ -0,0 +1,191 @@
+;;; test-custom-comments-comment-reformat.el --- Tests for cj/comment-reformat -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/comment-reformat function from custom-comments.el
+;;
+;; This function reformats multi-line comments into a single paragraph by:
+;; 1. Uncommenting the selected region
+;; 2. Joining lines together (via cj/join-line-or-region)
+;; 3. Re-commenting the result
+;; 4. Temporarily reducing fill-column by 3 during the join operation
+;;
+;; Dependencies:
+;; - Requires cj/join-line-or-region from custom-line-paragraph.el
+;; - We load the REAL module to test actual integration behavior
+;; - This follows our "test production code" guideline
+;; - If join-line-or-region has bugs, our tests will catch integration issues
+;;
+;; Cross-Language Testing Strategy:
+;; - Comprehensive testing in Emacs Lisp (12 tests)
+;; - Representative testing in Python and C (1 test each)
+;; - Function delegates to uncomment-region/comment-region, so we test OUR logic
+;; - See test-custom-comments-delete-buffer-comments.el for detailed rationale
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Load the real custom-line-paragraph module (for cj/join-line-or-region)
+(require 'custom-line-paragraph)
+
+;; Now load the actual production module
+(require 'custom-comments)
+
+;;; Test Helpers
+
+(defun test-comment-reformat-in-mode (mode content-before expected-after)
+ "Test comment reformatting in MODE.
+Insert CONTENT-BEFORE, select all, run cj/comment-reformat, verify EXPECTED-AFTER."
+ (with-temp-buffer
+ (transient-mark-mode 1) ; Enable transient-mark-mode for batch testing
+ (funcall mode)
+ (insert content-before)
+ (mark-whole-buffer)
+ (activate-mark) ; Explicitly activate the mark
+ (cj/comment-reformat)
+ (should (equal (string-trim (buffer-string)) (string-trim expected-after)))))
+
+;;; Emacs Lisp Tests (Primary Language - Comprehensive Coverage)
+
+(ert-deftest test-comment-reformat-elisp-simple-multiline ()
+ "Should join multiple commented lines into one."
+ (test-comment-reformat-in-mode
+ 'emacs-lisp-mode
+ ";; Line one\n;; Line two\n;; Line three"
+ ";; Line one Line two Line three"))
+
+(ert-deftest test-comment-reformat-elisp-preserves-content ()
+ "Should preserve text content after reformat."
+ (test-comment-reformat-in-mode
+ 'emacs-lisp-mode
+ ";; Hello world\n;; from Emacs"
+ ";; Hello world from Emacs"))
+
+(ert-deftest test-comment-reformat-elisp-restores-fill-column ()
+ "Should restore fill-column after operation."
+ (with-temp-buffer
+ (transient-mark-mode 1)
+ (emacs-lisp-mode)
+ (let ((original-fill-column fill-column))
+ (insert ";; Line one\n;; Line two")
+ (mark-whole-buffer)
+ (activate-mark)
+ (cj/comment-reformat)
+ (should (= fill-column original-fill-column)))))
+
+(ert-deftest test-comment-reformat-elisp-single-line ()
+ "Should handle single commented line."
+ (test-comment-reformat-in-mode
+ 'emacs-lisp-mode
+ ";; Single line comment"
+ ";; Single line comment"))
+
+(ert-deftest test-comment-reformat-elisp-empty-region ()
+ "Should error when trying to comment empty buffer."
+ (with-temp-buffer
+ (transient-mark-mode 1)
+ (emacs-lisp-mode)
+ (mark-whole-buffer)
+ (activate-mark)
+ (should-error (cj/comment-reformat))))
+
+(ert-deftest test-comment-reformat-elisp-whitespace-in-comments ()
+ "Should handle comments with only whitespace."
+ (test-comment-reformat-in-mode
+ 'emacs-lisp-mode
+ ";; \n;; \n;; text"
+ ";; text"))
+
+(ert-deftest test-comment-reformat-elisp-unicode ()
+ "Should handle unicode in comments."
+ (test-comment-reformat-in-mode
+ 'emacs-lisp-mode
+ ";; Hello 👋\n;; مرحبا café"
+ ";; Hello 👋 مرحبا café"))
+
+(ert-deftest test-comment-reformat-elisp-long-text ()
+ "Should handle many lines of comments."
+ (test-comment-reformat-in-mode
+ 'emacs-lisp-mode
+ ";; Line 1\n;; Line 2\n;; Line 3\n;; Line 4\n;; Line 5"
+ ";; Line 1 Line 2 Line 3 Line 4 Line 5"))
+
+(ert-deftest test-comment-reformat-elisp-indented-comments ()
+ "Should handle indented comments."
+ (with-temp-buffer
+ (transient-mark-mode 1)
+ (emacs-lisp-mode)
+ (insert " ;; Indented line 1\n ;; Indented line 2")
+ (mark-whole-buffer)
+ (activate-mark)
+ (cj/comment-reformat)
+ ;; After reformatting, should still be commented
+ (should (string-match-p ";;" (buffer-string)))
+ ;; Content should be joined
+ (should (string-match-p "line 1.*line 2" (buffer-string)))))
+
+(ert-deftest test-comment-reformat-elisp-region-at-buffer-start ()
+ "Should handle region at buffer start."
+ (with-temp-buffer
+ (transient-mark-mode 1)
+ (emacs-lisp-mode)
+ (insert ";; Start line 1\n;; Start line 2\n(setq x 1)")
+ (goto-char (point-min))
+ (set-mark (point))
+ (forward-line 2)
+ (activate-mark)
+ (cj/comment-reformat)
+ (should (string-match-p ";; Start line 1.*Start line 2" (buffer-string)))))
+
+(ert-deftest test-comment-reformat-elisp-no-region-active ()
+ "Should show message when no region selected."
+ (with-temp-buffer
+ (emacs-lisp-mode)
+ (insert ";; Comment line")
+ (deactivate-mark)
+ (let ((message-log-max nil)
+ (messages '()))
+ ;; Capture messages
+ (cl-letf (((symbol-function 'message)
+ (lambda (format-string &rest args)
+ (push (apply #'format format-string args) messages))))
+ (cj/comment-reformat)
+ (should (string-match-p "No region was selected" (car messages)))))))
+
+(ert-deftest test-comment-reformat-elisp-read-only-buffer ()
+ "Should signal error in read-only buffer."
+ (with-temp-buffer
+ (emacs-lisp-mode)
+ (insert ";; Line 1\n;; Line 2")
+ (mark-whole-buffer)
+ (read-only-mode 1)
+ (should-error (cj/comment-reformat))))
+
+;;; Python Tests (Hash-based comments)
+
+(ert-deftest test-comment-reformat-python-simple ()
+ "Should join Python hash comments."
+ (test-comment-reformat-in-mode
+ 'python-mode
+ "# Line one\n# Line two"
+ "# Line one Line two"))
+
+;;; C Tests (C-style comments)
+
+(ert-deftest test-comment-reformat-c-line-comments ()
+ "Should join C line comments (C-mode converts to block comments)."
+ (test-comment-reformat-in-mode
+ 'c-mode
+ "// Line one\n// Line two"
+ "/* Line one Line two */"))
+
+(provide 'test-custom-comments-comment-reformat)
+;;; test-custom-comments-comment-reformat.el ends here
diff --git a/tests/test-custom-comments-comment-simple-divider.el b/tests/test-custom-comments-comment-simple-divider.el
new file mode 100644
index 00000000..a61e6b4c
--- /dev/null
+++ b/tests/test-custom-comments-comment-simple-divider.el
@@ -0,0 +1,246 @@
+;;; test-custom-comments-comment-simple-divider.el --- Tests for cj/comment-simple-divider -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/comment-simple-divider function from custom-comments.el
+;;
+;; This function generates a simple 3-line comment divider banner:
+;; - Top line: comment-start + decoration chars
+;; - Middle line: comment-start + text
+;; - Bottom line: comment-start + decoration chars
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--comment-simple-divider)
+;; to avoid mocking user prompts. This follows our testing best practice
+;; of separating business logic from UI interaction.
+;;
+;; Cross-Language Testing Strategy:
+;; - Comprehensive testing in Emacs Lisp (our primary language)
+;; - Representative testing in Python and C (hash-based and C-style comments)
+;; - Function handles comment syntax generically, so testing 3 syntaxes
+;; proves cross-language compatibility
+;; - See test-custom-comments-delete-buffer-comments.el for detailed rationale
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-comments)
+
+;;; Test Helpers
+
+(defun test-simple-divider-at-column (column-pos comment-start comment-end decoration-char text length)
+ "Test cj/--comment-simple-divider at COLUMN-POS indentation.
+Insert spaces to reach COLUMN-POS, then call cj/--comment-simple-divider with
+COMMENT-START, COMMENT-END, DECORATION-CHAR, TEXT, and LENGTH.
+Returns the buffer string for assertions."
+ (with-temp-buffer
+ (when (> column-pos 0)
+ (insert (make-string column-pos ?\s)))
+ (cj/--comment-simple-divider comment-start comment-end decoration-char text length)
+ (buffer-string)))
+
+;;; Emacs Lisp Tests (Primary Language - Comprehensive Coverage)
+
+;;; Normal Cases
+
+(ert-deftest test-simple-divider-elisp-basic ()
+ "Should generate simple 3-line divider in emacs-lisp style."
+ (let ((result (test-simple-divider-at-column 0 ";;" "" "-" "Section Header" 70)))
+ ;; Should have 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Each line should start with ;;
+ (should (string-match-p "^;; -" result))
+ ;; Middle line should contain text
+ (should (string-match-p ";; Section Header" result))))
+
+(ert-deftest test-simple-divider-elisp-custom-decoration ()
+ "Should use custom decoration character."
+ (let ((result (test-simple-divider-at-column 0 ";;" "" "=" "Header" 70)))
+ (should (string-match-p ";; =" result))
+ (should-not (string-match-p ";; -" result))))
+
+(ert-deftest test-simple-divider-elisp-custom-text ()
+ "Should include custom text in middle line."
+ (let ((result (test-simple-divider-at-column 0 ";;" "" "-" "Custom Text Here" 70)))
+ (should (string-match-p ";; Custom Text Here" result))))
+
+(ert-deftest test-simple-divider-elisp-custom-length ()
+ "Should respect custom length."
+ (let* ((result (test-simple-divider-at-column 0 ";;" "" "-" "Header" 50))
+ (lines (split-string result "\n" t)))
+ ;; Should have 3 lines
+ (should (= 3 (length lines)))
+ ;; First and last lines (decoration) should be approximately 50 chars
+ (should (<= (length (car lines)) 51))
+ (should (>= (length (car lines)) 48))
+ (should (<= (length (car (last lines))) 51))
+ (should (>= (length (car (last lines))) 48))))
+
+(ert-deftest test-simple-divider-elisp-empty-text ()
+ "Should handle empty text string."
+ (let ((result (test-simple-divider-at-column 0 ";;" "" "-" "" 70)))
+ ;; Should still generate 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Middle line should just be comment-start
+ (should (string-match-p "^;; *\n" result))))
+
+(ert-deftest test-simple-divider-elisp-at-column-0 ()
+ "Should work at column 0."
+ (let ((result (test-simple-divider-at-column 0 ";;" "" "-""Header" 70)))
+ ;; First character should be semicolon
+ (should (string-prefix-p ";;" result))))
+
+(ert-deftest test-simple-divider-elisp-indented ()
+ "Should work when indented."
+ (let ((result (test-simple-divider-at-column 4 ";;" "" "-""Header" 70)))
+ ;; Result should start with spaces
+ (should (string-prefix-p " ;;" result))
+ ;; All lines should be indented
+ (dolist (line (split-string result "\n" t))
+ (should (string-prefix-p " ;;" line)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-simple-divider-elisp-minimum-length ()
+ "Should work with minimum viable length at column 0."
+ ;; Minimum length at column 0: 2 (;;) + 1 (space) + 1 (space) + 3 (dashes) = 7
+ (let ((result (test-simple-divider-at-column 0 ";;" "" "-""" 7)))
+ (should (= 3 (length (split-string result "\n" t))))))
+
+(ert-deftest test-simple-divider-elisp-minimum-length-indented ()
+ "Should work with minimum viable length when indented."
+ ;; At column 4, minimum is 4 + 2 + 1 + 1 + 3 = 11
+ (let ((result (test-simple-divider-at-column 4 ";;" "" "-""" 11)))
+ (should (= 3 (length (split-string result "\n" t))))))
+
+(ert-deftest test-simple-divider-elisp-very-long-length ()
+ "Should handle very long length."
+ (let ((result (test-simple-divider-at-column 0 ";;" "" "-""Header" 200)))
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Decoration lines should be very long
+ (let ((first-line (car (split-string result "\n" t))))
+ (should (> (length first-line) 100)))))
+
+(ert-deftest test-simple-divider-elisp-unicode-decoration ()
+ "Should handle unicode decoration character."
+ (let ((result (test-simple-divider-at-column 0 ";;" "" "─""Header" 70)))
+ (should (string-match-p "─" result))))
+
+(ert-deftest test-simple-divider-elisp-unicode-text ()
+ "Should handle unicode in text."
+ (let ((result (test-simple-divider-at-column 0 ";;" "" "-""Hello 👋 مرحبا café" 70)))
+ (should (string-match-p "👋" result))
+ (should (string-match-p "مرحبا" result))
+ (should (string-match-p "café" result))))
+
+(ert-deftest test-simple-divider-elisp-very-long-text ()
+ "Should handle very long text (may wrap or truncate)."
+ (let* ((long-text (make-string 100 ?x))
+ (result (test-simple-divider-at-column 0 ";;" "" "-"long-text 70)))
+ ;; Should still generate output (behavior may vary)
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Middle line should contain some of the text
+ (should (string-match-p "xxx" result))))
+
+(ert-deftest test-simple-divider-elisp-comment-end-empty ()
+ "Should handle empty comment-end correctly."
+ (let ((result (test-simple-divider-at-column 0 ";;" "" "-""Header" 70)))
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Lines should not have trailing comment-end
+ (should-not (string-match-p ";;.*;;$" result))))
+
+(ert-deftest test-simple-divider-elisp-max-indentation ()
+ "Should handle maximum practical indentation."
+ (let ((result (test-simple-divider-at-column 60 ";;" "" "-""Header" 100)))
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; All lines should start with 60 spaces
+ (dolist (line (split-string result "\n" t))
+ (should (string-prefix-p (make-string 60 ?\s) line)))))
+
+;;; Error Cases
+
+(ert-deftest test-simple-divider-elisp-length-too-small-column-0 ()
+ "Should error when length is too small at column 0."
+ (should-error
+ (test-simple-divider-at-column 0 ";;" "" "-" "Header" 5)
+ :type 'error))
+
+(ert-deftest test-simple-divider-elisp-length-too-small-indented ()
+ "Should error when length is too small for indentation level."
+ (should-error
+ (test-simple-divider-at-column 10 ";;" "" "-" "Header" 15)
+ :type 'error))
+
+(ert-deftest test-simple-divider-elisp-negative-length ()
+ "Should error with negative length."
+ (should-error
+ (test-simple-divider-at-column 0 ";;" "" "-" "Header" -10)
+ :type 'error))
+
+(ert-deftest test-simple-divider-elisp-zero-length ()
+ "Should error with zero length."
+ (should-error
+ (test-simple-divider-at-column 0 ";;" "" "-" "Header" 0)
+ :type 'error))
+
+(ert-deftest test-simple-divider-elisp-nil-decoration ()
+ "Should error when decoration-char is nil."
+ (should-error
+ (test-simple-divider-at-column 0 ";;" "" nil "Header" 70)
+ :type 'wrong-type-argument))
+
+(ert-deftest test-simple-divider-elisp-nil-text ()
+ "Should error when text is nil."
+ (should-error
+ (test-simple-divider-at-column 0 ";;" "" "-" nil 70)
+ :type 'wrong-type-argument))
+
+(ert-deftest test-simple-divider-elisp-non-integer-length ()
+ "Should error when length is not an integer."
+ (should-error
+ (test-simple-divider-at-column 0 ";;" "" "-""Header" "not-a-number")
+ :type 'wrong-type-argument))
+
+;;; Python Tests (Hash-based comments)
+
+(ert-deftest test-simple-divider-python-basic ()
+ "Should generate simple divider with Python comment syntax."
+ (let ((result (test-simple-divider-at-column 0 "#" "" "-""Section" 70)))
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-match-p "^# -" result))
+ (should (string-match-p "# Section" result))))
+
+(ert-deftest test-simple-divider-python-indented ()
+ "Should handle indented Python comments."
+ (let ((result (test-simple-divider-at-column 4 "#" "" "=""Function Section" 70)))
+ (should (string-prefix-p " #" result))
+ (should (string-match-p "Function Section" result))))
+
+;;; C Tests (C-style comments)
+
+(ert-deftest test-simple-divider-c-block-comments ()
+ "Should generate simple divider with C block comment syntax."
+ (let ((result (test-simple-divider-at-column 0 "/*" "*/" "-""Section" 70)))
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-match-p "^/\\* -" result))
+ (should (string-match-p "/\\* Section" result))
+ ;; Should include comment-end
+ (should (string-match-p "\\*/" result))))
+
+(ert-deftest test-simple-divider-c-line-comments ()
+ "Should generate simple divider with C line comment syntax."
+ (let ((result (test-simple-divider-at-column 0 "//" "" "=""Header" 70)))
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-match-p "^// =" result))
+ (should (string-match-p "// Header" result))))
+
+(provide 'test-custom-comments-comment-simple-divider)
+;;; test-custom-comments-comment-simple-divider.el ends here
diff --git a/tests/test-custom-comments-comment-unicode-box.el b/tests/test-custom-comments-comment-unicode-box.el
new file mode 100644
index 00000000..f34329c8
--- /dev/null
+++ b/tests/test-custom-comments-comment-unicode-box.el
@@ -0,0 +1,264 @@
+;;; test-custom-comments-comment-unicode-box.el --- Tests for cj/comment-unicode-box -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/comment-unicode-box function from custom-comments.el
+;;
+;; This function generates a 3-line unicode box comment:
+;; - Top line: comment-start + top-left corner + horizontal lines + top-right corner
+;; - Text line: comment-start + vertical bar + text + vertical bar
+;; - Bottom line: comment-start + bottom-left corner + horizontal lines + bottom-right corner
+;;
+;; Supports both 'single and 'double box styles with different unicode characters.
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--comment-unicode-box)
+;; to avoid mocking user prompts. This follows our testing best practice
+;; of separating business logic from UI interaction.
+;;
+;; Cross-Language Testing Strategy:
+;; - Comprehensive testing in Emacs Lisp (our primary language)
+;; - Representative testing in Python and C (hash-based and C-style comments)
+;; - Function handles comment syntax generically, so testing 3 syntaxes
+;; proves cross-language compatibility
+;; - See test-custom-comments-delete-buffer-comments.el for detailed rationale
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-comments)
+
+;;; Test Helpers
+
+(defun test-unicode-box-at-column (column-pos comment-start comment-end text length box-style)
+ "Test cj/--comment-unicode-box at COLUMN-POS indentation.
+Insert spaces to reach COLUMN-POS, then call cj/--comment-unicode-box with
+COMMENT-START, COMMENT-END, TEXT, LENGTH, and BOX-STYLE.
+Returns the buffer string for assertions."
+ (with-temp-buffer
+ (when (> column-pos 0)
+ (insert (make-string column-pos ?\s)))
+ (cj/--comment-unicode-box comment-start comment-end text length box-style)
+ (buffer-string)))
+
+;;; Emacs Lisp Tests (Primary Language - Comprehensive Coverage)
+
+;;; Normal Cases - Single Box Style
+
+(ert-deftest test-unicode-box-elisp-single-basic ()
+ "Should generate 3-line single-line unicode box in emacs-lisp style."
+ (let ((result (test-unicode-box-at-column 0 ";;" "" "Section Header" 70 'single)))
+ ;; Should have 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Should have single-line box characters
+ (should (string-match-p "┌" result))
+ (should (string-match-p "┐" result))
+ (should (string-match-p "└" result))
+ (should (string-match-p "┘" result))
+ (should (string-match-p "─" result))
+ (should (string-match-p "│" result))
+ ;; Should contain text
+ (should (string-match-p "Section Header" result))))
+
+(ert-deftest test-unicode-box-elisp-double-basic ()
+ "Should generate 3-line double-line unicode box in emacs-lisp style."
+ (let ((result (test-unicode-box-at-column 0 ";;" "" "Section Header" 70 'double)))
+ ;; Should have 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Should have double-line box characters
+ (should (string-match-p "╔" result))
+ (should (string-match-p "╗" result))
+ (should (string-match-p "╚" result))
+ (should (string-match-p "╝" result))
+ (should (string-match-p "═" result))
+ (should (string-match-p "║" result))
+ ;; Should contain text
+ (should (string-match-p "Section Header" result))))
+
+(ert-deftest test-unicode-box-elisp-single-vs-double ()
+ "Should use different characters for single vs double."
+ (let ((single-result (test-unicode-box-at-column 0 ";;" "" "Header" 70 'single))
+ (double-result (test-unicode-box-at-column 0 ";;" "" "Header" 70 'double)))
+ ;; Single should have single-line chars but not double
+ (should (string-match-p "─" single-result))
+ (should-not (string-match-p "═" single-result))
+ ;; Double should have double-line chars but not single
+ (should (string-match-p "═" double-result))
+ (should-not (string-match-p "─" double-result))))
+
+(ert-deftest test-unicode-box-elisp-custom-text ()
+ "Should include custom text in box."
+ (let ((result (test-unicode-box-at-column 0 ";;" "" "Custom Text Here" 70 'single)))
+ (should (string-match-p "Custom Text Here" result))))
+
+(ert-deftest test-unicode-box-elisp-empty-text ()
+ "Should handle empty text string."
+ (let ((result (test-unicode-box-at-column 0 ";;" "" "" 70 'single)))
+ ;; Should still generate 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Should have box characters
+ (should (string-match-p "┌" result))))
+
+(ert-deftest test-unicode-box-elisp-at-column-0 ()
+ "Should work at column 0."
+ (let ((result (test-unicode-box-at-column 0 ";;" "" "Header" 70 'single)))
+ ;; First character should be semicolon
+ (should (string-prefix-p ";;" result))))
+
+(ert-deftest test-unicode-box-elisp-indented ()
+ "Should work when indented."
+ (let ((result (test-unicode-box-at-column 4 ";;" "" "Header" 70 'single)))
+ ;; Result should start with spaces
+ (should (string-prefix-p " ;;" result))
+ ;; All lines should be indented
+ (dolist (line (split-string result "\n" t))
+ (should (string-prefix-p " ;;" line)))))
+
+(ert-deftest test-unicode-box-elisp-short-text ()
+ "Should handle short text properly."
+ (let ((result (test-unicode-box-at-column 0 ";;" "" "X" 70 'single)))
+ ;; Should have 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Text should be present
+ (should (string-match-p "X" result))))
+
+(ert-deftest test-unicode-box-elisp-long-text ()
+ "Should handle longer text."
+ (let ((result (test-unicode-box-at-column 0 ";;" "" "This is a longer header text" 70 'single)))
+ ;; Should have 3 lines
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Text should be present
+ (should (string-match-p "This is a longer header text" result))))
+
+;;; Boundary Cases
+
+(ert-deftest test-unicode-box-elisp-minimum-length ()
+ "Should work with minimum viable length."
+ (let ((result (test-unicode-box-at-column 0 ";;" "" "X" 15 'single)))
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-match-p "X" result))))
+
+(ert-deftest test-unicode-box-elisp-very-long-length ()
+ "Should handle very long length."
+ (let ((result (test-unicode-box-at-column 0 ";;" "" "Header" 200 'single)))
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Border lines should be very long
+ (let ((first-line (car (split-string result "\n" t))))
+ (should (> (length first-line) 100)))))
+
+(ert-deftest test-unicode-box-elisp-unicode-text ()
+ "Should handle unicode in text."
+ (let ((result (test-unicode-box-at-column 0 ";;" "" "Hello 👋 مرحبا café" 70 'single)))
+ (should (string-match-p "👋" result))
+ (should (string-match-p "مرحبا" result))
+ (should (string-match-p "café" result))))
+
+(ert-deftest test-unicode-box-elisp-very-long-text ()
+ "Should handle very long text."
+ (let* ((long-text (make-string 100 ?x))
+ (result (test-unicode-box-at-column 0 ";;" "" long-text 70 'single)))
+ ;; Should still generate output
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Middle line should contain some of the text
+ (should (string-match-p "xxx" result))))
+
+(ert-deftest test-unicode-box-elisp-comment-end-empty ()
+ "Should handle empty comment-end correctly."
+ (let ((result (test-unicode-box-at-column 0 ";;" "" "Header" 70 'single)))
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; Lines should not have trailing comment-end
+ (should-not (string-match-p ";;.*;;$" result))))
+
+(ert-deftest test-unicode-box-elisp-max-indentation ()
+ "Should handle maximum practical indentation."
+ (let ((result (test-unicode-box-at-column 60 ";;" "" "Header" 100 'single)))
+ (should (= 3 (length (split-string result "\n" t))))
+ ;; All lines should start with 60 spaces
+ (dolist (line (split-string result "\n" t))
+ (should (string-prefix-p (make-string 60 ?\s) line)))))
+
+;;; Error Cases
+
+(ert-deftest test-unicode-box-elisp-length-too-small ()
+ "Should error when length is too small."
+ (should-error
+ (test-unicode-box-at-column 0 ";;" "" "Header" 5 'single)
+ :type 'error))
+
+(ert-deftest test-unicode-box-elisp-negative-length ()
+ "Should error with negative length."
+ (should-error
+ (test-unicode-box-at-column 0 ";;" "" "Header" -10 'single)
+ :type 'error))
+
+(ert-deftest test-unicode-box-elisp-zero-length ()
+ "Should error with zero length."
+ (should-error
+ (test-unicode-box-at-column 0 ";;" "" "Header" 0 'single)
+ :type 'error))
+
+(ert-deftest test-unicode-box-elisp-nil-text ()
+ "Should error when text is nil."
+ (should-error
+ (test-unicode-box-at-column 0 ";;" "" nil 70 'single)
+ :type 'wrong-type-argument))
+
+(ert-deftest test-unicode-box-elisp-non-integer-length ()
+ "Should error when length is not an integer."
+ (should-error
+ (test-unicode-box-at-column 0 ";;" "" "Header" "not-a-number" 'single)
+ :type 'wrong-type-argument))
+
+(ert-deftest test-unicode-box-elisp-invalid-box-style ()
+ "Should handle invalid box-style gracefully."
+ ;; Function may use a default or error - either is acceptable
+ (let ((result (test-unicode-box-at-column 0 ";;" "" "Header" 70 'invalid)))
+ ;; Should still generate some output
+ (should (stringp result))))
+
+;;; Python Tests (Hash-based comments)
+
+(ert-deftest test-unicode-box-python-single ()
+ "Should generate unicode box with Python comment syntax."
+ (let ((result (test-unicode-box-at-column 0 "#" "" "Section" 70 'single)))
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-match-p "^# ┌" result))
+ (should (string-match-p "Section" result))))
+
+(ert-deftest test-unicode-box-python-double ()
+ "Should generate double-line unicode box with Python comment syntax."
+ (let ((result (test-unicode-box-at-column 0 "#" "" "Section" 70 'double)))
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-match-p "^# ╔" result))
+ (should (string-match-p "Section" result))))
+
+;;; C Tests (C-style comments)
+
+(ert-deftest test-unicode-box-c-block-comments-single ()
+ "Should generate unicode box with C block comment syntax."
+ (let ((result (test-unicode-box-at-column 0 "/*" "*/" "Section" 70 'single)))
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-match-p "^/\\* ┌" result))
+ (should (string-match-p "Section" result))
+ ;; Should include comment-end
+ (should (string-match-p "\\*/" result))))
+
+(ert-deftest test-unicode-box-c-block-comments-double ()
+ "Should generate double-line unicode box with C block comment syntax."
+ (let ((result (test-unicode-box-at-column 0 "/*" "*/" "Section" 70 'double)))
+ (should (= 3 (length (split-string result "\n" t))))
+ (should (string-match-p "^/\\* ╔" result))
+ (should (string-match-p "Section" result))
+ ;; Should include comment-end
+ (should (string-match-p "\\*/" result))))
+
+(provide 'test-custom-comments-comment-unicode-box)
+;;; test-custom-comments-comment-unicode-box.el ends here
diff --git a/tests/test-custom-comments-delete-buffer-comments.el b/tests/test-custom-comments-delete-buffer-comments.el
new file mode 100644
index 00000000..a21386f9
--- /dev/null
+++ b/tests/test-custom-comments-delete-buffer-comments.el
@@ -0,0 +1,224 @@
+;;; test-custom-comments-delete-buffer-comments.el --- Tests for cj/delete-buffer-comments -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/delete-buffer-comments function from custom-comments.el
+;;
+;; This function deletes all comments in the current buffer by delegating to
+;; Emacs' built-in `comment-kill` function.
+;;
+;; Cross-Language Testing Strategy:
+;; --------------------------------
+;; This function works across multiple programming languages/major modes because
+;; it delegates to `comment-kill`, which respects each mode's comment syntax
+;; (comment-start, comment-end).
+;;
+;; Rather than testing exhaustively in every language (8+ languages = 100+ tests),
+;; we test strategically:
+;;
+;; 1. EXTENSIVE testing in Emacs Lisp (our primary language):
+;; - ~15 tests covering all normal/boundary/error cases
+;; - Tests edge cases: empty buffers, inline comments, unicode, etc.
+;;
+;; 2. REPRESENTATIVE testing in Python and C:
+;; - ~3 tests each proving different comment syntaxes work
+;; - Python: hash-based comments (#)
+;; - C: C-style line (//) and block (/* */) comments
+;;
+;; Why this approach?
+;; - OUR code is simple: (goto-char (point-min)) + (comment-kill ...)
+;; - We're testing OUR integration logic, not Emacs' comment-kill implementation
+;; - After proving 3 different syntaxes work, additional languages have
+;; diminishing returns (testing Emacs internals, not our code)
+;; - Avoids test suite bloat (21 tests vs 100+) while maintaining confidence
+;; - Groups languages by similarity: C-style covers C/Java/Go/JavaScript/Rust
+;;
+;; See ai-prompts/quality-engineer.org: "Testing Framework/Library Integration"
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-comments)
+
+;;; Test Helper
+
+(defun test-delete-comments-in-mode (mode content-before expected-after)
+ "Test comment deletion in MODE.
+Insert CONTENT-BEFORE, run cj/delete-buffer-comments, verify EXPECTED-AFTER."
+ (with-temp-buffer
+ (funcall mode)
+ (insert content-before)
+ (cj/delete-buffer-comments)
+ (should (equal (string-trim (buffer-string)) (string-trim expected-after)))))
+
+;;; Emacs Lisp Tests (Primary Language - Comprehensive Coverage)
+
+(ert-deftest test-delete-comments-elisp-simple-line-comments ()
+ "Should delete simple line comments in emacs-lisp-mode."
+ (test-delete-comments-in-mode
+ 'emacs-lisp-mode
+ ";; This is a comment\n(defun foo () nil)"
+ "(defun foo () nil)"))
+
+(ert-deftest test-delete-comments-elisp-inline-comments ()
+ "Should delete inline/end-of-line comments."
+ (test-delete-comments-in-mode
+ 'emacs-lisp-mode
+ "(setq x 10) ;; set x to 10"
+ "(setq x 10)"))
+
+(ert-deftest test-delete-comments-elisp-only-comments ()
+ "Buffer with only comments should become empty."
+ (test-delete-comments-in-mode
+ 'emacs-lisp-mode
+ ";; Comment 1\n;; Comment 2\n;; Comment 3"
+ ""))
+
+(ert-deftest test-delete-comments-elisp-mixed-code-and-comments ()
+ "Should preserve code and delete all comments."
+ (test-delete-comments-in-mode
+ 'emacs-lisp-mode
+ ";; Header comment\n(defun foo ()\n ;; body comment\n (+ 1 2)) ;; inline"
+ "(defun foo ()\n\n (+ 1 2))"))
+
+(ert-deftest test-delete-comments-elisp-empty-buffer ()
+ "Should do nothing in empty buffer."
+ (test-delete-comments-in-mode
+ 'emacs-lisp-mode
+ ""
+ ""))
+
+(ert-deftest test-delete-comments-elisp-no-comments ()
+ "Should preserve all content when no comments exist."
+ (test-delete-comments-in-mode
+ 'emacs-lisp-mode
+ "(defun foo ()\n (+ 1 2))"
+ "(defun foo ()\n (+ 1 2))"))
+
+(ert-deftest test-delete-comments-elisp-whitespace-only-comments ()
+ "Should delete comments containing only whitespace."
+ (test-delete-comments-in-mode
+ 'emacs-lisp-mode
+ ";; \n;; \t\n(setq x 1)"
+ "(setq x 1)"))
+
+(ert-deftest test-delete-comments-elisp-unicode-in-comments ()
+ "Should handle unicode characters in comments."
+ (test-delete-comments-in-mode
+ 'emacs-lisp-mode
+ ";; Hello 👋 مرحبا café\n(setq x 1)"
+ "(setq x 1)"))
+
+(ert-deftest test-delete-comments-elisp-indented-comments ()
+ "Should delete comments at various indentation levels."
+ (test-delete-comments-in-mode
+ 'emacs-lisp-mode
+ "(defun foo ()\n ;; indented comment\n ;; more indented\n (+ 1 2))"
+ "(defun foo ()\n\n\n (+ 1 2))"))
+
+(ert-deftest test-delete-comments-elisp-special-chars-in-comments ()
+ "Should handle special characters in comments."
+ (test-delete-comments-in-mode
+ 'emacs-lisp-mode
+ ";; Special: !@#$%^&*()[]{}|\\/<>?\n(setq x 1)"
+ "(setq x 1)"))
+
+(ert-deftest test-delete-comments-elisp-point-not-at-beginning ()
+ "Should work regardless of initial point position."
+ (with-temp-buffer
+ (emacs-lisp-mode)
+ (insert ";; Comment 1\n(setq x 1)\n;; Comment 2")
+ (goto-char (point-max)) ; Point at end
+ (cj/delete-buffer-comments)
+ (should (equal (string-trim (buffer-string)) "(setq x 1)"))))
+
+(ert-deftest test-delete-comments-elisp-does-not-affect-kill-ring ()
+ "Should not add deleted comments to kill-ring."
+ (with-temp-buffer
+ (emacs-lisp-mode)
+ (insert ";; Comment\n(setq x 1)")
+ (setq kill-ring nil)
+ (cj/delete-buffer-comments)
+ (should (null kill-ring))))
+
+(ert-deftest test-delete-comments-elisp-read-only-buffer ()
+ "Should signal error in read-only buffer."
+ (with-temp-buffer
+ (emacs-lisp-mode)
+ (insert ";; Comment\n(setq x 1)")
+ (read-only-mode 1)
+ (should-error (cj/delete-buffer-comments))))
+
+(ert-deftest test-delete-comments-elisp-narrowed-buffer ()
+ "Should only affect visible region when narrowed."
+ (with-temp-buffer
+ (emacs-lisp-mode)
+ (insert ";; Comment 1\n(setq x 1)\n;; Comment 2\n(setq y 2)")
+ (goto-char (point-min))
+ (forward-line 2)
+ (narrow-to-region (point) (point-max))
+ (cj/delete-buffer-comments)
+ (widen)
+ ;; First comment should remain (was outside narrowed region)
+ ;; Second comment should be deleted
+ (should (string-match-p "Comment 1" (buffer-string)))
+ (should-not (string-match-p "Comment 2" (buffer-string)))))
+
+
+;;; Python Tests (Hash-based comments)
+
+(ert-deftest test-delete-comments-python-simple ()
+ "Should delete Python hash comments."
+ (test-delete-comments-in-mode
+ 'python-mode
+ "# This is a comment\ndef foo():\n return 42"
+ "def foo():\n return 42"))
+
+(ert-deftest test-delete-comments-python-inline ()
+ "Should delete inline Python comments."
+ (test-delete-comments-in-mode
+ 'python-mode
+ "x = 10 # set x to 10\ny = 20"
+ "x = 10\ny = 20"))
+
+(ert-deftest test-delete-comments-python-mixed ()
+ "Should preserve code and delete Python comments."
+ (test-delete-comments-in-mode
+ 'python-mode
+ "# Header\ndef foo():\n # body\n return 42 # inline"
+ "def foo():\n\n return 42"))
+
+;;; C Tests (C-style line and block comments)
+
+(ert-deftest test-delete-comments-c-line-comments ()
+ "Should delete C line comments (//)."
+ (test-delete-comments-in-mode
+ 'c-mode
+ "// This is a comment\nint main() {\n return 0;\n}"
+ "int main() {\n return 0;\n}"))
+
+(ert-deftest test-delete-comments-c-block-comments ()
+ "Should delete C block comments (/* */)."
+ (test-delete-comments-in-mode
+ 'c-mode
+ "/* Block comment */\nint x = 10;"
+ "int x = 10;"))
+
+(ert-deftest test-delete-comments-c-mixed ()
+ "Should delete both line and block comments in C."
+ (test-delete-comments-in-mode
+ 'c-mode
+ "// Line comment\n/* Block comment */\nint x = 10; // inline"
+ "int x = 10;"))
+
+(provide 'test-custom-comments-delete-buffer-comments)
+;;; test-custom-comments-delete-buffer-comments.el ends here
diff --git a/tests/test-custom-file-buffer-clear-to-bottom-of-buffer.el b/tests/test-custom-file-buffer-clear-to-bottom-of-buffer.el
new file mode 100644
index 00000000..969f9bb7
--- /dev/null
+++ b/tests/test-custom-file-buffer-clear-to-bottom-of-buffer.el
@@ -0,0 +1,163 @@
+;;; test-custom-file-buffer-clear-to-bottom-of-buffer.el --- Tests for cj/clear-to-bottom-of-buffer -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/clear-to-bottom-of-buffer function from custom-file-buffer.el
+;;
+;; This function deletes all text from point to the end of the current buffer.
+;; It does not save the deleted text in the kill ring.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Stub ps-print package
+(provide 'ps-print)
+
+;; Now load the actual production module
+(require 'custom-file-buffer)
+
+;;; Setup and Teardown
+
+(defun test-clear-to-bottom-setup ()
+ "Set up test environment."
+ (setq kill-ring nil))
+
+(defun test-clear-to-bottom-teardown ()
+ "Clean up test environment."
+ (setq kill-ring nil))
+
+;;; Normal Cases
+
+(ert-deftest test-clear-to-bottom-point-in-middle ()
+ "Should delete from point to end when point in middle."
+ (test-clear-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (goto-char (point-min))
+ (forward-line 1) ; Point at start of "Line 2"
+ (cj/clear-to-bottom-of-buffer)
+ (should (equal (buffer-string) "Line 1\n")))
+ (test-clear-to-bottom-teardown)))
+
+(ert-deftest test-clear-to-bottom-empty-buffer ()
+ "Should do nothing in empty buffer."
+ (test-clear-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (cj/clear-to-bottom-of-buffer)
+ (should (equal (buffer-string) "")))
+ (test-clear-to-bottom-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-clear-to-bottom-point-at-beginning ()
+ "Should delete entire buffer when point at beginning."
+ (test-clear-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (goto-char (point-min))
+ (cj/clear-to-bottom-of-buffer)
+ (should (equal (buffer-string) "")))
+ (test-clear-to-bottom-teardown)))
+
+(ert-deftest test-clear-to-bottom-point-at-end ()
+ "Should delete nothing when point at end."
+ (test-clear-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (goto-char (point-max))
+ (cj/clear-to-bottom-of-buffer)
+ (should (equal (buffer-string) "Line 1\nLine 2\nLine 3")))
+ (test-clear-to-bottom-teardown)))
+
+(ert-deftest test-clear-to-bottom-point-second-to-last-char ()
+ "Should delete last character when point at second-to-last."
+ (test-clear-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Hello")
+ (goto-char (1- (point-max))) ; Before 'o'
+ (cj/clear-to-bottom-of-buffer)
+ (should (equal (buffer-string) "Hell")))
+ (test-clear-to-bottom-teardown)))
+
+(ert-deftest test-clear-to-bottom-unicode-content ()
+ "Should handle unicode content."
+ (test-clear-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Hello 👋\nمرحبا\nWorld")
+ (goto-char (point-min))
+ (forward-line 1)
+ (cj/clear-to-bottom-of-buffer)
+ (should (equal (buffer-string) "Hello 👋\n")))
+ (test-clear-to-bottom-teardown)))
+
+(ert-deftest test-clear-to-bottom-narrowed-buffer ()
+ "Should respect narrowing."
+ (test-clear-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3\nLine 4")
+ (goto-char (point-min))
+ (forward-line 1)
+ (let ((start (point)))
+ (forward-line 2)
+ (narrow-to-region start (point))
+ (goto-char (point-min))
+ (forward-line 1) ; Point at "Line 3"
+ (cj/clear-to-bottom-of-buffer)
+ (should (equal (buffer-string) "Line 2\n"))))
+ (test-clear-to-bottom-teardown)))
+
+(ert-deftest test-clear-to-bottom-multiple-windows ()
+ "Should update all windows showing buffer."
+ (test-clear-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (goto-char (point-min))
+ (forward-line 1)
+ (cj/clear-to-bottom-of-buffer)
+ ;; Just verify content changed
+ (should (equal (buffer-string) "Line 1\n")))
+ (test-clear-to-bottom-teardown)))
+
+(ert-deftest test-clear-to-bottom-does-not-affect-kill-ring ()
+ "Should not add deleted text to kill ring."
+ (test-clear-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (goto-char (point-min))
+ (setq kill-ring nil)
+ (cj/clear-to-bottom-of-buffer)
+ (should (null kill-ring)))
+ (test-clear-to-bottom-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-clear-to-bottom-read-only-buffer ()
+ "Should signal error in read-only buffer."
+ (test-clear-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Read-only content")
+ (read-only-mode 1)
+ (goto-char (point-min))
+ (should-error (cj/clear-to-bottom-of-buffer)))
+ (test-clear-to-bottom-teardown)))
+
+(provide 'test-custom-file-buffer-clear-to-bottom-of-buffer)
+;;; test-custom-file-buffer-clear-to-bottom-of-buffer.el ends here
diff --git a/tests/test-custom-file-buffer-clear-to-top-of-buffer.el b/tests/test-custom-file-buffer-clear-to-top-of-buffer.el
new file mode 100644
index 00000000..18e3f71b
--- /dev/null
+++ b/tests/test-custom-file-buffer-clear-to-top-of-buffer.el
@@ -0,0 +1,162 @@
+;;; test-custom-file-buffer-clear-to-top-of-buffer.el --- Tests for cj/clear-to-top-of-buffer -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/clear-to-top-of-buffer function from custom-file-buffer.el
+;;
+;; This function deletes all text from point to the beginning of the current buffer.
+;; It does not save the deleted text in the kill ring.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Stub ps-print package
+(provide 'ps-print)
+
+;; Now load the actual production module
+(require 'custom-file-buffer)
+
+;;; Setup and Teardown
+
+(defun test-clear-to-top-setup ()
+ "Set up test environment."
+ (setq kill-ring nil))
+
+(defun test-clear-to-top-teardown ()
+ "Clean up test environment."
+ (setq kill-ring nil))
+
+;;; Normal Cases
+
+(ert-deftest test-clear-to-top-point-in-middle ()
+ "Should delete from beginning to point when point in middle."
+ (test-clear-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (goto-char (point-min))
+ (forward-line 2) ; Point at start of "Line 3"
+ (cj/clear-to-top-of-buffer)
+ (should (equal (buffer-string) "Line 3")))
+ (test-clear-to-top-teardown)))
+
+(ert-deftest test-clear-to-top-empty-buffer ()
+ "Should do nothing in empty buffer."
+ (test-clear-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (cj/clear-to-top-of-buffer)
+ (should (equal (buffer-string) "")))
+ (test-clear-to-top-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-clear-to-top-point-at-beginning ()
+ "Should delete nothing when point at beginning."
+ (test-clear-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (goto-char (point-min))
+ (cj/clear-to-top-of-buffer)
+ (should (equal (buffer-string) "Line 1\nLine 2\nLine 3")))
+ (test-clear-to-top-teardown)))
+
+(ert-deftest test-clear-to-top-point-at-end ()
+ "Should delete entire buffer when point at end."
+ (test-clear-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (goto-char (point-max))
+ (cj/clear-to-top-of-buffer)
+ (should (equal (buffer-string) "")))
+ (test-clear-to-top-teardown)))
+
+(ert-deftest test-clear-to-top-point-at-second-char ()
+ "Should delete first character when point at second."
+ (test-clear-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Hello")
+ (goto-char (1+ (point-min))) ; After 'H'
+ (cj/clear-to-top-of-buffer)
+ (should (equal (buffer-string) "ello")))
+ (test-clear-to-top-teardown)))
+
+(ert-deftest test-clear-to-top-unicode-content ()
+ "Should handle unicode content."
+ (test-clear-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Hello 👋\nمرحبا\nWorld")
+ (goto-char (point-min))
+ (forward-line 2)
+ (cj/clear-to-top-of-buffer)
+ (should (equal (buffer-string) "World")))
+ (test-clear-to-top-teardown)))
+
+(ert-deftest test-clear-to-top-narrowed-buffer ()
+ "Should respect narrowing."
+ (test-clear-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3\nLine 4")
+ (goto-char (point-min))
+ (forward-line 1)
+ (let ((start (point)))
+ (forward-line 2)
+ (narrow-to-region start (point))
+ (goto-char (point-min))
+ (forward-line 1) ; Point at "Line 3"
+ (cj/clear-to-top-of-buffer)
+ (should (equal (buffer-string) "Line 3\n"))))
+ (test-clear-to-top-teardown)))
+
+(ert-deftest test-clear-to-top-multiple-windows ()
+ "Should update all windows showing buffer."
+ (test-clear-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (goto-char (point-max))
+ (cj/clear-to-top-of-buffer)
+ ;; Just verify content changed
+ (should (equal (buffer-string) "")))
+ (test-clear-to-top-teardown)))
+
+(ert-deftest test-clear-to-top-does-not-affect-kill-ring ()
+ "Should not add deleted text to kill ring."
+ (test-clear-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (goto-char (point-max))
+ (setq kill-ring nil)
+ (cj/clear-to-top-of-buffer)
+ (should (null kill-ring)))
+ (test-clear-to-top-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-clear-to-top-read-only-buffer ()
+ "Should signal error in read-only buffer."
+ (test-clear-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Read-only content")
+ (read-only-mode 1)
+ (goto-char (point-max))
+ (should-error (cj/clear-to-top-of-buffer)))
+ (test-clear-to-top-teardown)))
+
+(provide 'test-custom-file-buffer-clear-to-top-of-buffer)
+;;; test-custom-file-buffer-clear-to-top-of-buffer.el ends here
diff --git a/tests/test-custom-file-buffer-copy-link-to-buffer-file.el b/tests/test-custom-file-buffer-copy-link-to-buffer-file.el
new file mode 100644
index 00000000..94d1e01e
--- /dev/null
+++ b/tests/test-custom-file-buffer-copy-link-to-buffer-file.el
@@ -0,0 +1,209 @@
+;;; test-custom-file-buffer-copy-link-to-buffer-file.el --- Tests for cj/copy-link-to-buffer-file -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/copy-link-to-buffer-file function from custom-file-buffer.el
+;;
+;; This function copies the full file:// path of the current buffer's file to
+;; the kill ring. For non-file buffers, it does nothing (no error).
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Stub ps-print package
+(provide 'ps-print)
+
+;; Now load the actual production module
+(require 'custom-file-buffer)
+
+;;; Setup and Teardown
+
+(defun test-copy-link-setup ()
+ "Set up test environment."
+ (setq kill-ring nil))
+
+(defun test-copy-link-teardown ()
+ "Clean up test environment."
+ ;; Kill all buffers visiting files in the test directory
+ (dolist (buf (buffer-list))
+ (when (buffer-file-name buf)
+ (when (string-prefix-p cj/test-base-dir (buffer-file-name buf))
+ (with-current-buffer buf
+ (set-buffer-modified-p nil)
+ (kill-buffer buf)))))
+ (cj/delete-test-base-dir)
+ (setq kill-ring nil))
+
+;;; Normal Cases
+
+(ert-deftest test-copy-link-simple-file ()
+ "Should copy file:// link for simple file buffer."
+ (test-copy-link-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (test-file (expand-file-name "test.txt" test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (with-current-buffer (find-file test-file)
+ (cj/copy-link-to-buffer-file)
+ (should (equal (car kill-ring) (concat "file://" test-file)))))
+ (test-copy-link-teardown)))
+
+(ert-deftest test-copy-link-non-file-buffer ()
+ "Should do nothing for non-file buffer without error."
+ (test-copy-link-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (setq kill-ring nil)
+ (cj/copy-link-to-buffer-file)
+ (should (null kill-ring)))
+ (test-copy-link-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-copy-link-unicode-filename ()
+ "Should handle unicode in filename."
+ (test-copy-link-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (test-file (expand-file-name "café.txt" test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (with-current-buffer (find-file test-file)
+ (cj/copy-link-to-buffer-file)
+ (should (equal (car kill-ring) (concat "file://" test-file)))))
+ (test-copy-link-teardown)))
+
+(ert-deftest test-copy-link-spaces-in-filename ()
+ "Should handle spaces in filename."
+ (test-copy-link-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (test-file (expand-file-name "my file.txt" test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (with-current-buffer (find-file test-file)
+ (cj/copy-link-to-buffer-file)
+ (should (equal (car kill-ring) (concat "file://" test-file)))))
+ (test-copy-link-teardown)))
+
+(ert-deftest test-copy-link-special-chars-filename ()
+ "Should handle special characters in filename."
+ (test-copy-link-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (test-file (expand-file-name "[test]-(1).txt" test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (with-current-buffer (find-file test-file)
+ (cj/copy-link-to-buffer-file)
+ (should (equal (car kill-ring) (concat "file://" test-file)))))
+ (test-copy-link-teardown)))
+
+(ert-deftest test-copy-link-very-long-path ()
+ "Should handle very long path."
+ (test-copy-link-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (long-name (make-string 200 ?x))
+ (test-file (expand-file-name (concat long-name ".txt") test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (with-current-buffer (find-file test-file)
+ (cj/copy-link-to-buffer-file)
+ (should (equal (car kill-ring) (concat "file://" test-file)))))
+ (test-copy-link-teardown)))
+
+(ert-deftest test-copy-link-hidden-file ()
+ "Should handle hidden file."
+ (test-copy-link-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (test-file (expand-file-name ".hidden" test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (with-current-buffer (find-file test-file)
+ (cj/copy-link-to-buffer-file)
+ (should (equal (car kill-ring) (concat "file://" test-file)))))
+ (test-copy-link-teardown)))
+
+(ert-deftest test-copy-link-no-extension ()
+ "Should handle file with no extension."
+ (test-copy-link-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (test-file (expand-file-name "README" test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (with-current-buffer (find-file test-file)
+ (cj/copy-link-to-buffer-file)
+ (should (equal (car kill-ring) (concat "file://" test-file)))))
+ (test-copy-link-teardown)))
+
+(ert-deftest test-copy-link-symlink-file ()
+ "Should use buffer's filename for symlink."
+ (test-copy-link-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (target-file (expand-file-name "target.txt" test-dir))
+ (link-file (expand-file-name "link.txt" test-dir)))
+ (with-temp-file target-file
+ (insert "content"))
+ (make-symbolic-link target-file link-file)
+ (with-current-buffer (find-file link-file)
+ (cj/copy-link-to-buffer-file)
+ ;; Should use the link name (what buffer-file-name returns)
+ (should (equal (car kill-ring) (concat "file://" (buffer-file-name))))))
+ (test-copy-link-teardown)))
+
+(ert-deftest test-copy-link-kill-ring-has-content ()
+ "Should add to kill ring when it already has content."
+ (test-copy-link-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (test-file (expand-file-name "test.txt" test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (kill-new "existing content")
+ (with-current-buffer (find-file test-file)
+ (cj/copy-link-to-buffer-file)
+ (should (equal (car kill-ring) (concat "file://" test-file)))
+ (should (equal (cadr kill-ring) "existing content"))))
+ (test-copy-link-teardown)))
+
+(ert-deftest test-copy-link-empty-kill-ring ()
+ "Should populate empty kill ring."
+ (test-copy-link-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (test-file (expand-file-name "test.txt" test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (setq kill-ring nil)
+ (with-current-buffer (find-file test-file)
+ (cj/copy-link-to-buffer-file)
+ (should (equal (car kill-ring) (concat "file://" test-file)))
+ (should (= (length kill-ring) 1))))
+ (test-copy-link-teardown)))
+
+(ert-deftest test-copy-link-scratch-buffer ()
+ "Should do nothing for *scratch* buffer."
+ (test-copy-link-setup)
+ (unwind-protect
+ (progn
+ (setq kill-ring nil)
+ (with-current-buffer "*scratch*"
+ (cj/copy-link-to-buffer-file)
+ (should (null kill-ring))))
+ (test-copy-link-teardown)))
+
+(provide 'test-custom-file-buffer-copy-link-to-buffer-file)
+;;; test-custom-file-buffer-copy-link-to-buffer-file.el ends here
diff --git a/tests/test-custom-file-buffer-copy-path-to-buffer-file-as-kill.el b/tests/test-custom-file-buffer-copy-path-to-buffer-file-as-kill.el
new file mode 100644
index 00000000..e7a6f64b
--- /dev/null
+++ b/tests/test-custom-file-buffer-copy-path-to-buffer-file-as-kill.el
@@ -0,0 +1,205 @@
+;;; test-custom-file-buffer-copy-path-to-buffer-file-as-kill.el --- Tests for cj/copy-path-to-buffer-file-as-kill -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/copy-path-to-buffer-file-as-kill function from custom-file-buffer.el
+;;
+;; This function copies the full path of the current buffer's file to the kill ring
+;; and returns the path. It signals an error if the buffer is not visiting a file.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Stub ps-print package
+(provide 'ps-print)
+
+;; Now load the actual production module
+(require 'custom-file-buffer)
+
+;;; Setup and Teardown
+
+(defun test-copy-path-setup ()
+ "Set up test environment."
+ (setq kill-ring nil))
+
+(defun test-copy-path-teardown ()
+ "Clean up test environment."
+ ;; Kill all buffers visiting files in the test directory
+ (dolist (buf (buffer-list))
+ (when (buffer-file-name buf)
+ (when (string-prefix-p cj/test-base-dir (buffer-file-name buf))
+ (with-current-buffer buf
+ (set-buffer-modified-p nil)
+ (kill-buffer buf)))))
+ (cj/delete-test-base-dir)
+ (setq kill-ring nil))
+
+;;; Normal Cases
+
+(ert-deftest test-copy-path-simple-file ()
+ "Should copy absolute path for simple file buffer."
+ (test-copy-path-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (test-file (expand-file-name "test.txt" test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (with-current-buffer (find-file test-file)
+ (let ((result (cj/copy-path-to-buffer-file-as-kill)))
+ (should (equal result test-file))
+ (should (equal (car kill-ring) test-file)))))
+ (test-copy-path-teardown)))
+
+(ert-deftest test-copy-path-returns-path ()
+ "Should return the path value."
+ (test-copy-path-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (test-file (expand-file-name "test.txt" test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (with-current-buffer (find-file test-file)
+ (let ((result (cj/copy-path-to-buffer-file-as-kill)))
+ (should (stringp result))
+ (should (equal result test-file)))))
+ (test-copy-path-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-copy-path-unicode-filename ()
+ "Should handle unicode in filename."
+ (test-copy-path-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (test-file (expand-file-name "café.txt" test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (with-current-buffer (find-file test-file)
+ (cj/copy-path-to-buffer-file-as-kill)
+ (should (equal (car kill-ring) test-file))))
+ (test-copy-path-teardown)))
+
+(ert-deftest test-copy-path-spaces-in-filename ()
+ "Should handle spaces in filename."
+ (test-copy-path-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (test-file (expand-file-name "my file.txt" test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (with-current-buffer (find-file test-file)
+ (cj/copy-path-to-buffer-file-as-kill)
+ (should (equal (car kill-ring) test-file))))
+ (test-copy-path-teardown)))
+
+(ert-deftest test-copy-path-special-chars-filename ()
+ "Should handle special characters in filename."
+ (test-copy-path-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (test-file (expand-file-name "[test]-(1).txt" test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (with-current-buffer (find-file test-file)
+ (cj/copy-path-to-buffer-file-as-kill)
+ (should (equal (car kill-ring) test-file))))
+ (test-copy-path-teardown)))
+
+(ert-deftest test-copy-path-very-long-path ()
+ "Should handle very long path."
+ (test-copy-path-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (long-name (make-string 200 ?x))
+ (test-file (expand-file-name (concat long-name ".txt") test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (with-current-buffer (find-file test-file)
+ (cj/copy-path-to-buffer-file-as-kill)
+ (should (equal (car kill-ring) test-file))))
+ (test-copy-path-teardown)))
+
+(ert-deftest test-copy-path-hidden-file ()
+ "Should handle hidden file."
+ (test-copy-path-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (test-file (expand-file-name ".hidden" test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (with-current-buffer (find-file test-file)
+ (cj/copy-path-to-buffer-file-as-kill)
+ (should (equal (car kill-ring) test-file))))
+ (test-copy-path-teardown)))
+
+(ert-deftest test-copy-path-no-extension ()
+ "Should handle file with no extension."
+ (test-copy-path-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (test-file (expand-file-name "README" test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (with-current-buffer (find-file test-file)
+ (cj/copy-path-to-buffer-file-as-kill)
+ (should (equal (car kill-ring) test-file))))
+ (test-copy-path-teardown)))
+
+(ert-deftest test-copy-path-symlink-file ()
+ "Should use buffer's filename for symlink."
+ (test-copy-path-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (target-file (expand-file-name "target.txt" test-dir))
+ (link-file (expand-file-name "link.txt" test-dir)))
+ (with-temp-file target-file
+ (insert "content"))
+ (make-symbolic-link target-file link-file)
+ (with-current-buffer (find-file link-file)
+ (cj/copy-path-to-buffer-file-as-kill)
+ (should (equal (car kill-ring) (buffer-file-name)))))
+ (test-copy-path-teardown)))
+
+(ert-deftest test-copy-path-kill-ring-has-content ()
+ "Should add to kill ring when it already has content."
+ (test-copy-path-setup)
+ (unwind-protect
+ (let* ((test-dir (cj/create-test-subdirectory "test"))
+ (test-file (expand-file-name "test.txt" test-dir)))
+ (with-temp-file test-file
+ (insert "content"))
+ (kill-new "existing content")
+ (with-current-buffer (find-file test-file)
+ (cj/copy-path-to-buffer-file-as-kill)
+ (should (equal (car kill-ring) test-file))
+ (should (equal (cadr kill-ring) "existing content"))))
+ (test-copy-path-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-copy-path-non-file-buffer ()
+ "Should signal user-error for non-file buffer."
+ (test-copy-path-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (should-error (cj/copy-path-to-buffer-file-as-kill) :type 'user-error))
+ (test-copy-path-teardown)))
+
+(ert-deftest test-copy-path-scratch-buffer ()
+ "Should signal user-error for *scratch* buffer."
+ (test-copy-path-setup)
+ (unwind-protect
+ (with-current-buffer "*scratch*"
+ (should-error (cj/copy-path-to-buffer-file-as-kill) :type 'user-error))
+ (test-copy-path-teardown)))
+
+(provide 'test-custom-file-buffer-copy-path-to-buffer-file-as-kill)
+;;; test-custom-file-buffer-copy-path-to-buffer-file-as-kill.el ends here
diff --git a/tests/test-custom-file-buffer-copy-whole-buffer.el b/tests/test-custom-file-buffer-copy-whole-buffer.el
new file mode 100644
index 00000000..a0546b18
--- /dev/null
+++ b/tests/test-custom-file-buffer-copy-whole-buffer.el
@@ -0,0 +1,194 @@
+;;; test-custom-file-buffer-copy-whole-buffer.el --- Tests for cj/copy-whole-buffer -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/copy-whole-buffer function from custom-file-buffer.el
+;;
+;; This function copies the entire contents of the current buffer to the kill ring.
+;; Point and mark are left exactly where they were. No transient region is created.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Stub ps-print package
+(provide 'ps-print)
+
+;; Now load the actual production module
+(require 'custom-file-buffer)
+
+;;; Setup and Teardown
+
+(defun test-copy-whole-buffer-setup ()
+ "Set up test environment."
+ (setq kill-ring nil))
+
+(defun test-copy-whole-buffer-teardown ()
+ "Clean up test environment."
+ (setq kill-ring nil))
+
+;;; Normal Cases
+
+(ert-deftest test-copy-whole-buffer-simple-text ()
+ "Should copy simple text content to kill ring."
+ (test-copy-whole-buffer-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Hello, world!")
+ (cj/copy-whole-buffer)
+ (should (equal (car kill-ring) "Hello, world!")))
+ (test-copy-whole-buffer-teardown)))
+
+(ert-deftest test-copy-whole-buffer-preserves-point ()
+ "Should preserve point position."
+ (test-copy-whole-buffer-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Hello, world!")
+ (goto-char 7) ; Position in middle
+ (cj/copy-whole-buffer)
+ (should (= (point) 7)))
+ (test-copy-whole-buffer-teardown)))
+
+(ert-deftest test-copy-whole-buffer-preserves-mark ()
+ "Should preserve mark position."
+ (test-copy-whole-buffer-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Hello, world!")
+ (push-mark 5)
+ (goto-char 10)
+ (cj/copy-whole-buffer)
+ (should (= (mark) 5))
+ (should (= (point) 10)))
+ (test-copy-whole-buffer-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-copy-whole-buffer-empty ()
+ "Should handle empty buffer."
+ (test-copy-whole-buffer-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (cj/copy-whole-buffer)
+ (should (equal (car kill-ring) "")))
+ (test-copy-whole-buffer-teardown)))
+
+(ert-deftest test-copy-whole-buffer-large ()
+ "Should handle very large buffer."
+ (test-copy-whole-buffer-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (let ((large-content (make-string 100000 ?x)))
+ (insert large-content)
+ (cj/copy-whole-buffer)
+ (should (equal (car kill-ring) large-content))))
+ (test-copy-whole-buffer-teardown)))
+
+(ert-deftest test-copy-whole-buffer-unicode ()
+ "Should handle unicode content (emoji, RTL text)."
+ (test-copy-whole-buffer-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Hello 👋 مرحبا")
+ (cj/copy-whole-buffer)
+ (should (equal (car kill-ring) "Hello 👋 مرحبا")))
+ (test-copy-whole-buffer-teardown)))
+
+(ert-deftest test-copy-whole-buffer-binary ()
+ "Should handle binary content."
+ (test-copy-whole-buffer-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert (string 0 1 2 255))
+ (cj/copy-whole-buffer)
+ (should (equal (car kill-ring) (string 0 1 2 255))))
+ (test-copy-whole-buffer-teardown)))
+
+(ert-deftest test-copy-whole-buffer-only-whitespace ()
+ "Should handle buffer with only whitespace."
+ (test-copy-whole-buffer-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert " \t\n ")
+ (cj/copy-whole-buffer)
+ (should (equal (car kill-ring) " \t\n ")))
+ (test-copy-whole-buffer-teardown)))
+
+(ert-deftest test-copy-whole-buffer-newlines-at-boundaries ()
+ "Should handle newlines at start/end."
+ (test-copy-whole-buffer-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "\n\nHello\n\n")
+ (cj/copy-whole-buffer)
+ (should (equal (car kill-ring) "\n\nHello\n\n")))
+ (test-copy-whole-buffer-teardown)))
+
+(ert-deftest test-copy-whole-buffer-narrowed ()
+ "Should copy only visible region in narrowed buffer."
+ (test-copy-whole-buffer-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3\n")
+ (goto-char (point-min))
+ (forward-line 1)
+ (narrow-to-region (point) (progn (forward-line 1) (point)))
+ (cj/copy-whole-buffer)
+ ;; Should copy only the narrowed region
+ (should (equal (car kill-ring) "Line 2\n")))
+ (test-copy-whole-buffer-teardown)))
+
+(ert-deftest test-copy-whole-buffer-read-only ()
+ "Should work in read-only buffer."
+ (test-copy-whole-buffer-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Read-only content")
+ (read-only-mode 1)
+ (cj/copy-whole-buffer)
+ (should (equal (car kill-ring) "Read-only content")))
+ (test-copy-whole-buffer-teardown)))
+
+(ert-deftest test-copy-whole-buffer-kill-ring-has-content ()
+ "Should add to kill ring when it already has content."
+ (test-copy-whole-buffer-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "New content")
+ (kill-new "existing content")
+ (cj/copy-whole-buffer)
+ (should (equal (car kill-ring) "New content"))
+ (should (equal (cadr kill-ring) "existing content")))
+ (test-copy-whole-buffer-teardown)))
+
+(ert-deftest test-copy-whole-buffer-multiline ()
+ "Should preserve multiline content."
+ (test-copy-whole-buffer-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (cj/copy-whole-buffer)
+ (should (equal (car kill-ring) "Line 1\nLine 2\nLine 3")))
+ (test-copy-whole-buffer-teardown)))
+
+(ert-deftest test-copy-whole-buffer-no-properties ()
+ "Should strip text properties."
+ (test-copy-whole-buffer-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert (propertize "Hello" 'face 'bold))
+ (cj/copy-whole-buffer)
+ (should (equal (car kill-ring) "Hello"))
+ (should (null (text-properties-at 0 (car kill-ring)))))
+ (test-copy-whole-buffer-teardown)))
+
+(provide 'test-custom-file-buffer-copy-whole-buffer)
+;;; test-custom-file-buffer-copy-whole-buffer.el ends here
diff --git a/tests/test-custom-misc-count-words.el b/tests/test-custom-misc-count-words.el
new file mode 100644
index 00000000..f2bf793f
--- /dev/null
+++ b/tests/test-custom-misc-count-words.el
@@ -0,0 +1,148 @@
+;;; test-custom-misc-count-words.el --- Tests for cj/--count-words -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--count-words function from custom-misc.el
+;;
+;; This function counts words in a region using Emacs's built-in count-words.
+;; A word is defined by Emacs's word boundaries, which generally means
+;; sequences of word-constituent characters separated by whitespace or punctuation.
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--count-words) to avoid
+;; mocking region selection. This follows our testing best practice of
+;; separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-misc)
+
+;;; Test Helpers
+
+(defun test-count-words (input-text)
+ "Test cj/--count-words on INPUT-TEXT.
+Returns the word count."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--count-words (point-min) (point-max))))
+
+;;; Normal Cases
+
+(ert-deftest test-count-words-multiple-words ()
+ "Should count multiple words."
+ (should (= 5 (test-count-words "The quick brown fox jumps"))))
+
+(ert-deftest test-count-words-single-word ()
+ "Should count single word."
+ (should (= 1 (test-count-words "hello"))))
+
+(ert-deftest test-count-words-with-punctuation ()
+ "Should count words with punctuation."
+ (should (= 5 (test-count-words "Hello, world! How are you?"))))
+
+(ert-deftest test-count-words-multiple-spaces ()
+ "Should count words separated by multiple spaces."
+ (should (= 3 (test-count-words "hello world test"))))
+
+(ert-deftest test-count-words-with-newlines ()
+ "Should count words across newlines."
+ (should (= 6 (test-count-words "line one\nline two\nline three"))))
+
+(ert-deftest test-count-words-with-tabs ()
+ "Should count words separated by tabs."
+ (should (= 3 (test-count-words "hello\tworld\ttest"))))
+
+(ert-deftest test-count-words-mixed-whitespace ()
+ "Should count words with mixed whitespace."
+ (should (= 4 (test-count-words "hello \t world\n\ntest end"))))
+
+(ert-deftest test-count-words-hyphenated ()
+ "Should count hyphenated words."
+ ;; Emacs treats hyphens as word separators in count-words
+ (should (= 7 (test-count-words "This is state-of-the-art technology"))))
+
+(ert-deftest test-count-words-contractions ()
+ "Should count contractions."
+ ;; Emacs treats apostrophes as word separators in count-words
+ (should (= 6 (test-count-words "don't can't won't"))))
+
+(ert-deftest test-count-words-numbers ()
+ "Should count numbers as words."
+ (should (= 6 (test-count-words "The year 2024 has 365 days"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-count-words-empty-string ()
+ "Should return 0 for empty string."
+ (should (= 0 (test-count-words ""))))
+
+(ert-deftest test-count-words-only-whitespace ()
+ "Should return 0 for whitespace-only text."
+ (should (= 0 (test-count-words " \t\n\n "))))
+
+(ert-deftest test-count-words-only-punctuation ()
+ "Should count punctuation-only text."
+ ;; Emacs may count consecutive punctuation as a word
+ (should (= 1 (test-count-words "!@#$%^&*()"))))
+
+(ert-deftest test-count-words-leading-trailing-spaces ()
+ "Should count words ignoring leading/trailing spaces."
+ (should (= 3 (test-count-words " hello world test "))))
+
+(ert-deftest test-count-words-unicode ()
+ "Should count Unicode words."
+ (should (= 3 (test-count-words "café résumé naïve"))))
+
+(ert-deftest test-count-words-very-long-text ()
+ "Should handle very long text."
+ (let ((long-text (mapconcat (lambda (_) "word") (make-list 1000 nil) " ")))
+ (should (= 1000 (test-count-words long-text)))))
+
+(ert-deftest test-count-words-multiline-paragraph ()
+ "Should count words in multi-line paragraph."
+ (let ((text "This is a paragraph
+that spans multiple
+lines with various
+words in it."))
+ (should (= 13 (test-count-words text)))))
+
+;;; Error Cases
+
+(ert-deftest test-count-words-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "hello world")
+ (cj/--count-words (point-max) (point-min)))
+ :type 'error))
+
+(ert-deftest test-count-words-empty-region ()
+ "Should return 0 for empty region (start == end)."
+ (with-temp-buffer
+ (insert "hello world")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (should (= 0 (cj/--count-words pos pos))))))
+
+(ert-deftest test-count-words-partial-region ()
+ "Should count words only in specified region."
+ (with-temp-buffer
+ (insert "one two three four five")
+ ;; Count only "two three four" (positions roughly in middle)
+ (goto-char (point-min))
+ (search-forward "two")
+ (let ((start (match-beginning 0)))
+ (search-forward "four")
+ (let ((end (match-end 0)))
+ (should (= 3 (cj/--count-words start end)))))))
+
+(provide 'test-custom-misc-count-words)
+;;; test-custom-misc-count-words.el ends here
diff --git a/tests/test-custom-misc-format-region.el b/tests/test-custom-misc-format-region.el
new file mode 100644
index 00000000..c40a8898
--- /dev/null
+++ b/tests/test-custom-misc-format-region.el
@@ -0,0 +1,161 @@
+;;; test-custom-misc-format-region.el --- Tests for cj/--format-region -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--format-region function from custom-misc.el
+;;
+;; This function reformats text by applying three operations:
+;; 1. untabify - converts tabs to spaces
+;; 2. indent-region - reindents according to major mode
+;; 3. delete-trailing-whitespace - removes trailing whitespace
+;;
+;; Note: indent-region behavior is major-mode dependent. We test in
+;; emacs-lisp-mode and fundamental-mode for predictable results.
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--format-region)
+;; to avoid mocking region selection. This follows our testing best practice
+;; of separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-misc)
+
+;;; Test Helpers
+
+(defun test-format-region (input-text &optional mode)
+ "Test cj/--format-region on INPUT-TEXT.
+MODE is the major mode to use (defaults to fundamental-mode).
+Returns the buffer string after operation."
+ (with-temp-buffer
+ (funcall (or mode #'fundamental-mode))
+ (insert input-text)
+ (cj/--format-region (point-min) (point-max))
+ (buffer-string)))
+
+;;; Normal Cases - Tab Conversion
+
+(ert-deftest test-format-region-converts-tabs ()
+ "Should convert tabs to spaces."
+ (let ((result (test-format-region "hello\tworld")))
+ (should-not (string-match-p "\t" result))
+ (should (string-match-p " " result))))
+
+(ert-deftest test-format-region-multiple-tabs ()
+ "Should convert multiple tabs."
+ (let ((result (test-format-region "\t\thello\t\tworld\t\t")))
+ (should-not (string-match-p "\t" result))))
+
+;;; Normal Cases - Trailing Whitespace
+
+(ert-deftest test-format-region-removes-trailing-spaces ()
+ "Should remove trailing spaces."
+ (let ((result (test-format-region "hello world ")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-format-region-removes-trailing-tabs ()
+ "Should remove trailing tabs."
+ (let ((result (test-format-region "hello world\t\t")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-format-region-removes-trailing-mixed ()
+ "Should remove trailing mixed whitespace."
+ (let ((result (test-format-region "hello world \t \t ")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-format-region-multiline-trailing ()
+ "Should remove trailing whitespace from multiple lines."
+ (let ((result (test-format-region "line1 \nline2\t\t\nline3 \t ")))
+ (should (string= result "line1\nline2\nline3"))))
+
+;;; Normal Cases - Combined Operations
+
+(ert-deftest test-format-region-tabs-and-trailing ()
+ "Should handle both tabs and trailing whitespace."
+ (let ((result (test-format-region "\thello\tworld\t\t")))
+ (should-not (string-match-p "\t" result))
+ ;; Should not end with whitespace
+ (should-not (string-match-p "[ \t]+$" result))))
+
+(ert-deftest test-format-region-preserves-interior-spaces ()
+ "Should preserve interior spaces while fixing edges."
+ (let ((result (test-format-region "\thello world\t")))
+ (should (string-match-p "hello world" result))
+ (should-not (string-match-p "\t" result))))
+
+;;; Normal Cases - Indentation (Mode-Specific)
+
+(ert-deftest test-format-region-elisp-indentation ()
+ "Should reindent Elisp code."
+ (let* ((input "(defun foo ()\n(+ 1 2))")
+ (result (test-format-region input #'emacs-lisp-mode))
+ (lines (split-string result "\n")))
+ ;; The inner form should be indented - second line should start with 2 spaces
+ (should (= 2 (length lines)))
+ (should (string-prefix-p "(defun foo ()" (car lines)))
+ (should (string-prefix-p " " (cadr lines)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-format-region-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-format-region "")))
+ (should (string= result ""))))
+
+(ert-deftest test-format-region-no-issues ()
+ "Should handle text with no formatting issues (no-op)."
+ (let ((result (test-format-region "hello world")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-format-region-only-whitespace ()
+ "Should handle text with only whitespace."
+ (let ((result (test-format-region "\t \t ")))
+ ;; Should become empty or just spaces, no tabs
+ (should-not (string-match-p "\t" result))))
+
+(ert-deftest test-format-region-single-line ()
+ "Should handle single line."
+ (let ((result (test-format-region "\thello\t")))
+ (should-not (string-match-p "\t" result))))
+
+(ert-deftest test-format-region-very-long-text ()
+ "Should handle very long text."
+ (let* ((long-text (mapconcat (lambda (_) "\thello\t") (make-list 100 nil) "\n"))
+ (result (test-format-region long-text)))
+ (should-not (string-match-p "\t" result))))
+
+(ert-deftest test-format-region-newlines-preserved ()
+ "Should preserve newlines while fixing formatting."
+ (let ((result (test-format-region "line1\t \nline2\t \nline3\t ")))
+ (should (= 2 (cl-count ?\n result)))))
+
+;;; Error Cases
+
+(ert-deftest test-format-region-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "hello world")
+ (cj/--format-region (point-max) (point-min)))
+ :type 'error))
+
+(ert-deftest test-format-region-empty-region ()
+ "Should handle empty region (start == end) without error."
+ (with-temp-buffer
+ (insert "hello world")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (cj/--format-region pos pos)
+ ;; Should complete without error
+ (should (string= (buffer-string) "hello world")))))
+
+(provide 'test-custom-misc-format-region)
+;;; test-custom-misc-format-region.el ends here
diff --git a/tests/test-custom-misc-jump-to-matching-paren.el b/tests/test-custom-misc-jump-to-matching-paren.el
new file mode 100644
index 00000000..973b6dfa
--- /dev/null
+++ b/tests/test-custom-misc-jump-to-matching-paren.el
@@ -0,0 +1,197 @@
+;;; test-custom-misc-jump-to-matching-paren.el --- Tests for cj/jump-to-matching-paren -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/jump-to-matching-paren function from custom-misc.el
+;;
+;; This function jumps to matching delimiters using Emacs's sexp navigation.
+;; It works with any delimiter that has matching syntax according to the
+;; current syntax table (parentheses, brackets, braces, etc.).
+;;
+;; Unlike other functions in this test suite, this is an INTERACTIVE function
+;; that moves point and displays messages. We test it as an integration test
+;; by setting up buffers, positioning point, calling the function, and
+;; verifying where point ends up.
+;;
+;; Key behaviors:
+;; - When on opening delimiter: jump forward to matching closing delimiter
+;; - When on closing delimiter: jump backward to matching opening delimiter
+;; - When just after closing delimiter: jump backward to matching opening
+;; - When not on delimiter: display message, don't move
+;; - When no matching delimiter: display error message, don't move
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-misc)
+
+;;; Test Helpers
+
+(defun test-jump-to-matching-paren (text point-position)
+ "Test cj/jump-to-matching-paren with TEXT and point at POINT-POSITION.
+Returns the new point position after calling the function.
+POINT-POSITION is 1-indexed (1 = first character)."
+ (with-temp-buffer
+ (emacs-lisp-mode) ; Use elisp mode for proper syntax table
+ (insert text)
+ (goto-char point-position)
+ (cj/jump-to-matching-paren)
+ (point)))
+
+;;; Normal Cases - Forward Jump (Opening to Closing)
+
+(ert-deftest test-jump-paren-forward-simple ()
+ "Should jump forward from opening paren to closing paren."
+ ;; Text: "(hello)"
+ ;; Start at position 1 (on opening paren)
+ ;; Should end at position 8 (after closing paren)
+ (should (= 8 (test-jump-to-matching-paren "(hello)" 1))))
+
+(ert-deftest test-jump-paren-forward-nested ()
+ "Should jump forward over nested parens."
+ ;; Text: "(foo (bar))"
+ ;; Start at position 1 (on outer opening paren)
+ ;; Should end at position 12 (after outer closing paren)
+ (should (= 12 (test-jump-to-matching-paren "(foo (bar))" 1))))
+
+(ert-deftest test-jump-paren-forward-inner-nested ()
+ "Should jump forward from inner opening paren."
+ ;; Text: "(foo (bar))"
+ ;; Start at position 6 (on inner opening paren)
+ ;; Should end at position 11 (after inner closing paren)
+ (should (= 11 (test-jump-to-matching-paren "(foo (bar))" 6))))
+
+(ert-deftest test-jump-bracket-forward ()
+ "Should jump forward from opening bracket."
+ ;; Text: "[1 2 3]"
+ ;; Start at position 1
+ ;; Should end at position 8
+ (should (= 8 (test-jump-to-matching-paren "[1 2 3]" 1))))
+
+;; Note: Braces are not treated as matching delimiters in emacs-lisp-mode
+;; so we don't test them here
+
+;;; Normal Cases - Backward Jump (Closing to Opening)
+
+(ert-deftest test-jump-paren-backward-simple ()
+ "Should jump backward from closing paren to opening paren."
+ ;; Text: "(hello)"
+ ;; Start at position 7 (on closing paren)
+ ;; Should end at position 2 (after opening paren)
+ (should (= 2 (test-jump-to-matching-paren "(hello)" 7))))
+
+(ert-deftest test-jump-paren-backward-nested ()
+ "Should jump backward over nested parens from after outer closing."
+ ;; Text: "(foo (bar))"
+ ;; Start at position 12 (after outer closing paren)
+ ;; backward-sexp goes back to before opening paren
+ (should (= 1 (test-jump-to-matching-paren "(foo (bar))" 12))))
+
+(ert-deftest test-jump-paren-backward-inner-nested ()
+ "Should jump backward from inner closing paren."
+ ;; Text: "(foo (bar))"
+ ;; Start at position 10 (on inner closing paren)
+ ;; Should end at position 7 (after inner opening paren)
+ (should (= 7 (test-jump-to-matching-paren "(foo (bar))" 10))))
+
+(ert-deftest test-jump-bracket-backward ()
+ "Should jump backward from after closing bracket."
+ ;; Text: "[1 2 3]"
+ ;; Start at position 8 (after ])
+ ;; backward-sexp goes back one sexp
+ (should (= 1 (test-jump-to-matching-paren "[1 2 3]" 8))))
+
+;;; Normal Cases - Jump from After Closing Delimiter
+
+(ert-deftest test-jump-paren-after-closing ()
+ "Should jump backward when just after closing paren."
+ ;; Text: "(hello)"
+ ;; Start at position 8 (after closing paren)
+ ;; backward-sexp goes back one sexp, ending before the opening paren
+ (should (= 1 (test-jump-to-matching-paren "(hello)" 8))))
+
+;;; Boundary Cases - No Movement
+
+(ert-deftest test-jump-paren-not-on-delimiter ()
+ "Should not move when not on delimiter."
+ ;; Text: "(hello world)"
+ ;; Start at position 3 (on 'e' in hello)
+ ;; Should stay at position 3
+ (should (= 3 (test-jump-to-matching-paren "(hello world)" 3))))
+
+(ert-deftest test-jump-paren-on-whitespace ()
+ "Should not move when on whitespace."
+ ;; Text: "(hello world)"
+ ;; Start at position 7 (on space)
+ ;; Should stay at position 7
+ (should (= 7 (test-jump-to-matching-paren "(hello world)" 7))))
+
+;;; Boundary Cases - Unmatched Delimiters
+
+(ert-deftest test-jump-paren-unmatched-opening ()
+ "Should not move from unmatched opening paren."
+ ;; Text: "(hello"
+ ;; Start at position 1 (on opening paren with no closing)
+ ;; Should stay at position 1
+ (should (= 1 (test-jump-to-matching-paren "(hello" 1))))
+
+(ert-deftest test-jump-paren-unmatched-closing ()
+ "Should move to beginning from unmatched closing paren."
+ ;; Text: "hello)"
+ ;; Start at position 6 (on closing paren with no opening)
+ ;; backward-sexp with unmatched closing paren goes to beginning
+ (should (= 1 (test-jump-to-matching-paren "hello)" 6))))
+
+;;; Boundary Cases - Empty Delimiters
+
+(ert-deftest test-jump-paren-empty ()
+ "Should jump over empty parens."
+ ;; Text: "()"
+ ;; Start at position 1
+ ;; Should end at position 3
+ (should (= 3 (test-jump-to-matching-paren "()" 1))))
+
+(ert-deftest test-jump-paren-empty-backward ()
+ "Should stay put when on closing paren of empty parens."
+ ;; Text: "()"
+ ;; Start at position 2 (on closing paren)
+ ;; backward-sexp from closing of empty parens gives an error, so stays at 2
+ (should (= 2 (test-jump-to-matching-paren "()" 2))))
+
+;;; Boundary Cases - Multiple Delimiter Types
+
+(ert-deftest test-jump-paren-mixed-delimiters ()
+ "Should jump over mixed delimiter types."
+ ;; Text: "(foo [bar {baz}])"
+ ;; Start at position 1 (on opening paren)
+ ;; Should end at position 18 (after closing paren)
+ (should (= 18 (test-jump-to-matching-paren "(foo [bar {baz}])" 1))))
+
+(ert-deftest test-jump-bracket-in-parens ()
+ "Should jump from bracket inside parens."
+ ;; Text: "(foo [bar])"
+ ;; Start at position 6 (on opening bracket)
+ ;; Should end at position 11 (after closing bracket)
+ (should (= 11 (test-jump-to-matching-paren "(foo [bar])" 6))))
+
+;;; Complex Cases - Strings and Comments
+
+(ert-deftest test-jump-paren-over-string ()
+ "Should jump over parens containing strings."
+ ;; Text: "(\"hello (world)\")"
+ ;; Start at position 1 (on opening paren)
+ ;; Should end at position 18 (after closing paren)
+ ;; The parens in the string should be ignored
+ (should (= 18 (test-jump-to-matching-paren "(\"hello (world)\")" 1))))
+
+(provide 'test-custom-misc-jump-to-matching-paren)
+;;; test-custom-misc-jump-to-matching-paren.el ends here
diff --git a/tests/test-custom-misc-replace-fraction-glyphs.el b/tests/test-custom-misc-replace-fraction-glyphs.el
new file mode 100644
index 00000000..81d1546e
--- /dev/null
+++ b/tests/test-custom-misc-replace-fraction-glyphs.el
@@ -0,0 +1,185 @@
+;;; test-custom-misc-replace-fraction-glyphs.el --- Tests for cj/--replace-fraction-glyphs -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--replace-fraction-glyphs function from custom-misc.el
+;;
+;; This function bidirectionally converts between text fractions (1/4) and
+;; Unicode fraction glyphs (¼). It supports 5 common fractions:
+;; - 1/4 ↔ ¼
+;; - 1/2 ↔ ½
+;; - 3/4 ↔ ¾
+;; - 1/3 ↔ ⅓
+;; - 2/3 ↔ ⅔
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--replace-fraction-glyphs)
+;; to avoid mocking prefix arguments. This follows our testing best practice
+;; of separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-misc)
+
+;;; Test Helpers
+
+(defun test-replace-fraction-glyphs (input-text to-glyphs)
+ "Test cj/--replace-fraction-glyphs on INPUT-TEXT.
+TO-GLYPHS determines conversion direction.
+Returns the buffer string after operation."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--replace-fraction-glyphs (point-min) (point-max) to-glyphs)
+ (buffer-string)))
+
+;;; Normal Cases - Text to Glyphs
+
+(ert-deftest test-replace-fraction-glyphs-text-to-glyph-quarter ()
+ "Should convert 1/4 to ¼."
+ (let ((result (test-replace-fraction-glyphs "1/4" t)))
+ (should (string= result "¼"))))
+
+(ert-deftest test-replace-fraction-glyphs-text-to-glyph-half ()
+ "Should convert 1/2 to ½."
+ (let ((result (test-replace-fraction-glyphs "1/2" t)))
+ (should (string= result "½"))))
+
+(ert-deftest test-replace-fraction-glyphs-text-to-glyph-three-quarters ()
+ "Should convert 3/4 to ¾."
+ (let ((result (test-replace-fraction-glyphs "3/4" t)))
+ (should (string= result "¾"))))
+
+(ert-deftest test-replace-fraction-glyphs-text-to-glyph-third ()
+ "Should convert 1/3 to ⅓."
+ (let ((result (test-replace-fraction-glyphs "1/3" t)))
+ (should (string= result "⅓"))))
+
+(ert-deftest test-replace-fraction-glyphs-text-to-glyph-two-thirds ()
+ "Should convert 2/3 to ⅔."
+ (let ((result (test-replace-fraction-glyphs "2/3" t)))
+ (should (string= result "⅔"))))
+
+(ert-deftest test-replace-fraction-glyphs-text-to-glyph-multiple ()
+ "Should convert multiple fractions in text."
+ (let ((result (test-replace-fraction-glyphs "Use 1/4 cup and 1/2 teaspoon" t)))
+ (should (string= result "Use ¼ cup and ½ teaspoon"))))
+
+(ert-deftest test-replace-fraction-glyphs-text-to-glyph-all-types ()
+ "Should convert all fraction types."
+ (let ((result (test-replace-fraction-glyphs "1/4 1/2 3/4 1/3 2/3" t)))
+ (should (string= result "¼ ½ ¾ ⅓ ⅔"))))
+
+;;; Normal Cases - Glyphs to Text
+
+(ert-deftest test-replace-fraction-glyphs-glyph-to-text-quarter ()
+ "Should convert ¼ to 1/4."
+ (let ((result (test-replace-fraction-glyphs "¼" nil)))
+ (should (string= result "1/4"))))
+
+(ert-deftest test-replace-fraction-glyphs-glyph-to-text-half ()
+ "Should convert ½ to 1/2."
+ (let ((result (test-replace-fraction-glyphs "½" nil)))
+ (should (string= result "1/2"))))
+
+(ert-deftest test-replace-fraction-glyphs-glyph-to-text-three-quarters ()
+ "Should convert ¾ to 3/4."
+ (let ((result (test-replace-fraction-glyphs "¾" nil)))
+ (should (string= result "3/4"))))
+
+(ert-deftest test-replace-fraction-glyphs-glyph-to-text-third ()
+ "Should convert ⅓ to 1/3."
+ (let ((result (test-replace-fraction-glyphs "⅓" nil)))
+ (should (string= result "1/3"))))
+
+(ert-deftest test-replace-fraction-glyphs-glyph-to-text-two-thirds ()
+ "Should convert ⅔ to 2/3."
+ (let ((result (test-replace-fraction-glyphs "⅔" nil)))
+ (should (string= result "2/3"))))
+
+(ert-deftest test-replace-fraction-glyphs-glyph-to-text-multiple ()
+ "Should convert multiple glyphs in text."
+ (let ((result (test-replace-fraction-glyphs "Use ¼ cup and ½ teaspoon" nil)))
+ (should (string= result "Use 1/4 cup and 1/2 teaspoon"))))
+
+(ert-deftest test-replace-fraction-glyphs-glyph-to-text-all-types ()
+ "Should convert all glyph types."
+ (let ((result (test-replace-fraction-glyphs "¼ ½ ¾ ⅓ ⅔" nil)))
+ (should (string= result "1/4 1/2 3/4 1/3 2/3"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-replace-fraction-glyphs-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-replace-fraction-glyphs "" t)))
+ (should (string= result ""))))
+
+(ert-deftest test-replace-fraction-glyphs-no-fractions-to-glyphs ()
+ "Should handle text with no fractions (no-op) when converting to glyphs."
+ (let ((result (test-replace-fraction-glyphs "hello world" t)))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-replace-fraction-glyphs-no-fractions-to-text ()
+ "Should handle text with no glyphs (no-op) when converting to text."
+ (let ((result (test-replace-fraction-glyphs "hello world" nil)))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-replace-fraction-glyphs-at-start ()
+ "Should handle fraction at start of text."
+ (let ((result (test-replace-fraction-glyphs "1/2 of the total" t)))
+ (should (string= result "½ of the total"))))
+
+(ert-deftest test-replace-fraction-glyphs-at-end ()
+ "Should handle fraction at end of text."
+ (let ((result (test-replace-fraction-glyphs "Reduce by 1/4" t)))
+ (should (string= result "Reduce by ¼"))))
+
+(ert-deftest test-replace-fraction-glyphs-repeated ()
+ "Should handle repeated fractions."
+ (let ((result (test-replace-fraction-glyphs "1/4 and 1/4 and 1/4" t)))
+ (should (string= result "¼ and ¼ and ¼"))))
+
+(ert-deftest test-replace-fraction-glyphs-very-long-text ()
+ "Should handle very long text with many fractions."
+ (let* ((long-text (mapconcat (lambda (_) "1/4") (make-list 50 nil) " "))
+ (result (test-replace-fraction-glyphs long-text t)))
+ (should (string-match-p "¼" result))
+ (should-not (string-match-p "1/4" result))))
+
+(ert-deftest test-replace-fraction-glyphs-bidirectional ()
+ "Should correctly convert back and forth."
+ (let* ((original "Use 1/4 cup")
+ (to-glyph (test-replace-fraction-glyphs original t))
+ (back-to-text (test-replace-fraction-glyphs to-glyph nil)))
+ (should (string= to-glyph "Use ¼ cup"))
+ (should (string= back-to-text original))))
+
+;;; Error Cases
+
+(ert-deftest test-replace-fraction-glyphs-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "1/4")
+ (cj/--replace-fraction-glyphs (point-max) (point-min) t))
+ :type 'error))
+
+(ert-deftest test-replace-fraction-glyphs-empty-region ()
+ "Should handle empty region (start == end) without error."
+ (with-temp-buffer
+ (insert "1/4")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (cj/--replace-fraction-glyphs pos pos t)
+ ;; Should complete without error
+ (should (string= (buffer-string) "1/4")))))
+
+(provide 'test-custom-misc-replace-fraction-glyphs)
+;;; test-custom-misc-replace-fraction-glyphs.el ends here
diff --git a/tests/test-custom-ordering-alphabetize.el b/tests/test-custom-ordering-alphabetize.el
new file mode 100644
index 00000000..c609e324
--- /dev/null
+++ b/tests/test-custom-ordering-alphabetize.el
@@ -0,0 +1,176 @@
+;;; test-custom-ordering-alphabetize.el --- Tests for cj/--alphabetize-region -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--alphabetize-region function from custom-ordering.el
+;;
+;; This function alphabetically sorts words in a region.
+;; It splits by whitespace and commas, sorts alphabetically, and joins with ", ".
+;;
+;; Examples:
+;; Input: "zebra apple banana"
+;; Output: "apple, banana, zebra"
+;;
+;; Input: "dog, cat, bird"
+;; Output: "bird, cat, dog"
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--alphabetize-region) to avoid
+;; mocking region selection. This follows our testing best practice of
+;; separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-ordering)
+
+;;; Test Helpers
+
+(defun test-alphabetize (input-text)
+ "Test cj/--alphabetize-region on INPUT-TEXT.
+Returns the sorted, comma-separated string."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--alphabetize-region (point-min) (point-max))))
+
+;;; Normal Cases - Simple Words
+
+(ert-deftest test-alphabetize-simple-words ()
+ "Should alphabetize simple words."
+ (let ((result (test-alphabetize "zebra apple banana")))
+ (should (string= result "apple, banana, zebra"))))
+
+(ert-deftest test-alphabetize-already-sorted ()
+ "Should handle already sorted words."
+ (let ((result (test-alphabetize "apple banana cherry")))
+ (should (string= result "apple, banana, cherry"))))
+
+(ert-deftest test-alphabetize-reverse-order ()
+ "Should alphabetize reverse-ordered words."
+ (let ((result (test-alphabetize "zebra yankee xray")))
+ (should (string= result "xray, yankee, zebra"))))
+
+(ert-deftest test-alphabetize-two-words ()
+ "Should alphabetize two words."
+ (let ((result (test-alphabetize "world hello")))
+ (should (string= result "hello, world"))))
+
+;;; Normal Cases - With Commas
+
+(ert-deftest test-alphabetize-comma-separated ()
+ "Should alphabetize comma-separated words."
+ (let ((result (test-alphabetize "dog, cat, bird")))
+ (should (string= result "bird, cat, dog"))))
+
+(ert-deftest test-alphabetize-comma-separated-with-spaces ()
+ "Should handle comma-separated with various spacing."
+ (let ((result (test-alphabetize "dog,cat,bird")))
+ (should (string= result "bird, cat, dog"))))
+
+;;; Normal Cases - With Newlines
+
+(ert-deftest test-alphabetize-multiline ()
+ "Should alphabetize words across multiple lines."
+ (let ((result (test-alphabetize "zebra\napple\nbanana")))
+ (should (string= result "apple, banana, zebra"))))
+
+(ert-deftest test-alphabetize-mixed-separators ()
+ "Should alphabetize with mixed separators (spaces, commas, newlines)."
+ (let ((result (test-alphabetize "zebra, apple\nbanana cherry")))
+ (should (string= result "apple, banana, cherry, zebra"))))
+
+;;; Normal Cases - Case Sensitivity
+
+(ert-deftest test-alphabetize-case-sensitive ()
+ "Should sort case-sensitively (uppercase before lowercase)."
+ (let ((result (test-alphabetize "zebra Apple banana")))
+ ;; string-lessp sorts uppercase before lowercase
+ (should (string= result "Apple, banana, zebra"))))
+
+(ert-deftest test-alphabetize-mixed-case ()
+ "Should handle mixed case words."
+ (let ((result (test-alphabetize "ZEBRA apple BANANA")))
+ (should (string= result "BANANA, ZEBRA, apple"))))
+
+;;; Normal Cases - Numbers and Special Characters
+
+(ert-deftest test-alphabetize-with-numbers ()
+ "Should alphabetize numbers as strings."
+ (let ((result (test-alphabetize "10 2 1 20")))
+ ;; Alphabetic sort: "1", "10", "2", "20"
+ (should (string= result "1, 10, 2, 20"))))
+
+(ert-deftest test-alphabetize-mixed-alphanumeric ()
+ "Should alphabetize mixed alphanumeric content."
+ (let ((result (test-alphabetize "item2 item1 item10")))
+ (should (string= result "item1, item10, item2"))))
+
+(ert-deftest test-alphabetize-with-punctuation ()
+ "Should alphabetize words with punctuation."
+ (let ((result (test-alphabetize "world! hello? test.")))
+ (should (string= result "hello?, test., world!"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-alphabetize-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-alphabetize "")))
+ (should (string= result ""))))
+
+(ert-deftest test-alphabetize-single-word ()
+ "Should handle single word."
+ (let ((result (test-alphabetize "hello")))
+ (should (string= result "hello"))))
+
+(ert-deftest test-alphabetize-only-whitespace ()
+ "Should handle whitespace-only text."
+ (let ((result (test-alphabetize " \n\n\t\t ")))
+ (should (string= result ""))))
+
+(ert-deftest test-alphabetize-duplicates ()
+ "Should handle duplicate words."
+ (let ((result (test-alphabetize "apple banana apple cherry")))
+ (should (string= result "apple, apple, banana, cherry"))))
+
+(ert-deftest test-alphabetize-many-commas ()
+ "Should handle multiple consecutive commas."
+ (let ((result (test-alphabetize "apple,,,banana,,,cherry")))
+ (should (string= result "apple, banana, cherry"))))
+
+(ert-deftest test-alphabetize-very-long-list ()
+ "Should handle very long list."
+ (let* ((words (mapcar (lambda (i) (format "word%03d" i)) (number-sequence 100 1 -1)))
+ (input (mapconcat #'identity words " "))
+ (result (test-alphabetize input))
+ (sorted-words (split-string result ", ")))
+ (should (= 100 (length sorted-words)))
+ (should (string= "word001" (car sorted-words)))
+ (should (string= "word100" (car (last sorted-words))))))
+
+;;; Error Cases
+
+(ert-deftest test-alphabetize-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "hello world")
+ (cj/--alphabetize-region (point-max) (point-min)))
+ :type 'error))
+
+(ert-deftest test-alphabetize-empty-region ()
+ "Should handle empty region (start == end)."
+ (with-temp-buffer
+ (insert "hello world")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (should (string= "" (cj/--alphabetize-region pos pos))))))
+
+(provide 'test-custom-ordering-alphabetize)
+;;; test-custom-ordering-alphabetize.el ends here
diff --git a/tests/test-custom-ordering-arrayify.el b/tests/test-custom-ordering-arrayify.el
new file mode 100644
index 00000000..9aedbc46
--- /dev/null
+++ b/tests/test-custom-ordering-arrayify.el
@@ -0,0 +1,215 @@
+;;; test-custom-ordering-arrayify.el --- Tests for cj/--arrayify -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--arrayify function from custom-ordering.el
+;;
+;; This function converts lines of text into a quoted, comma-separated array format.
+;; It splits input by whitespace, wraps each element in quotes, and joins with ", ".
+;;
+;; Examples:
+;; Input: "apple\nbanana\ncherry"
+;; Output: "\"apple\", \"banana\", \"cherry\""
+;;
+;; Input: "one two three" (with single quotes)
+;; Output: "'one', 'two', 'three'"
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--arrayify) to avoid
+;; mocking user input for quote characters. This follows our testing best
+;; practice of separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-ordering)
+
+;;; Test Helpers
+
+(defun test-arrayify (input-text quote)
+ "Test cj/--arrayify on INPUT-TEXT with QUOTE character.
+Returns the transformed string."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--arrayify (point-min) (point-max) quote)))
+
+(defun test-arrayify-with-prefix-suffix (input-text quote prefix suffix)
+ "Test cj/--arrayify with PREFIX and SUFFIX on INPUT-TEXT.
+Returns the transformed string."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--arrayify (point-min) (point-max) quote prefix suffix)))
+
+;;; Normal Cases - Double Quotes
+
+(ert-deftest test-arrayify-single-line-double-quotes ()
+ "Should arrayify single line with double quotes."
+ (let ((result (test-arrayify "apple banana cherry" "\"")))
+ (should (string= result "\"apple\", \"banana\", \"cherry\""))))
+
+(ert-deftest test-arrayify-multiple-lines-double-quotes ()
+ "Should arrayify multiple lines with double quotes."
+ (let ((result (test-arrayify "apple\nbanana\ncherry" "\"")))
+ (should (string= result "\"apple\", \"banana\", \"cherry\""))))
+
+(ert-deftest test-arrayify-mixed-whitespace-double-quotes ()
+ "Should arrayify text with mixed whitespace using double quotes."
+ (let ((result (test-arrayify "apple \n\n banana\t\tcherry" "\"")))
+ (should (string= result "\"apple\", \"banana\", \"cherry\""))))
+
+;;; Normal Cases - Single Quotes
+
+(ert-deftest test-arrayify-single-line-single-quotes ()
+ "Should arrayify single line with single quotes."
+ (let ((result (test-arrayify "one two three" "'")))
+ (should (string= result "'one', 'two', 'three'"))))
+
+(ert-deftest test-arrayify-multiple-lines-single-quotes ()
+ "Should arrayify multiple lines with single quotes."
+ (let ((result (test-arrayify "one\ntwo\nthree" "'")))
+ (should (string= result "'one', 'two', 'three'"))))
+
+;;; Normal Cases - Various Quote Types
+
+(ert-deftest test-arrayify-backticks ()
+ "Should arrayify with backticks."
+ (let ((result (test-arrayify "foo bar baz" "`")))
+ (should (string= result "`foo`, `bar`, `baz`"))))
+
+(ert-deftest test-arrayify-no-quotes ()
+ "Should arrayify with empty quote string."
+ (let ((result (test-arrayify "alpha beta gamma" "")))
+ (should (string= result "alpha, beta, gamma"))))
+
+(ert-deftest test-arrayify-square-brackets ()
+ "Should arrayify with square brackets as quotes."
+ (let ((result (test-arrayify "x y z" "[]")))
+ (should (string= result "[]x[], []y[], []z[]"))))
+
+;;; Normal Cases - Various Content
+
+(ert-deftest test-arrayify-with-numbers ()
+ "Should arrayify numbers."
+ (let ((result (test-arrayify "1 2 3 4 5" "\"")))
+ (should (string= result "\"1\", \"2\", \"3\", \"4\", \"5\""))))
+
+(ert-deftest test-arrayify-with-punctuation ()
+ "Should arrayify words with punctuation."
+ (let ((result (test-arrayify "hello! world? test." "\"")))
+ (should (string= result "\"hello!\", \"world?\", \"test.\""))))
+
+(ert-deftest test-arrayify-mixed-content ()
+ "Should arrayify mixed alphanumeric content."
+ (let ((result (test-arrayify "item1 item2 item3" "\"")))
+ (should (string= result "\"item1\", \"item2\", \"item3\""))))
+
+;;; Boundary Cases
+
+(ert-deftest test-arrayify-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-arrayify "" "\"")))
+ (should (string= result ""))))
+
+(ert-deftest test-arrayify-single-word ()
+ "Should arrayify single word."
+ (let ((result (test-arrayify "hello" "\"")))
+ (should (string= result "\"hello\""))))
+
+(ert-deftest test-arrayify-only-whitespace ()
+ "Should handle whitespace-only text."
+ (let ((result (test-arrayify " \n\n\t\t " "\"")))
+ (should (string= result ""))))
+
+(ert-deftest test-arrayify-leading-trailing-whitespace ()
+ "Should ignore leading and trailing whitespace."
+ (let ((result (test-arrayify " apple banana " "\"")))
+ (should (string= result "\"apple\", \"banana\""))))
+
+(ert-deftest test-arrayify-very-long-list ()
+ "Should handle very long list."
+ (let* ((words (make-list 100 "word"))
+ (input (mapconcat #'identity words " "))
+ (result (test-arrayify input "\"")))
+ (should (= 100 (length (split-string result ", "))))))
+
+(ert-deftest test-arrayify-two-words ()
+ "Should arrayify two words."
+ (let ((result (test-arrayify "hello world" "\"")))
+ (should (string= result "\"hello\", \"world\""))))
+
+;;; Normal Cases - Prefix/Suffix
+
+(ert-deftest test-arrayify-with-square-brackets ()
+ "Should arrayify with square brackets prefix/suffix."
+ (let ((result (test-arrayify-with-prefix-suffix "apple banana cherry" "\"" "[" "]")))
+ (should (string= result "[\"apple\", \"banana\", \"cherry\"]"))))
+
+(ert-deftest test-arrayify-with-parens ()
+ "Should arrayify with parentheses prefix/suffix."
+ (let ((result (test-arrayify-with-prefix-suffix "one two three" "\"" "(" ")")))
+ (should (string= result "(\"one\", \"two\", \"three\")"))))
+
+(ert-deftest test-arrayify-unquoted-with-brackets ()
+ "Should create unquoted list with brackets."
+ (let ((result (test-arrayify-with-prefix-suffix "a b c" "" "[" "]")))
+ (should (string= result "[a, b, c]"))))
+
+(ert-deftest test-arrayify-single-quotes-with-brackets ()
+ "Should create single-quoted array with brackets."
+ (let ((result (test-arrayify-with-prefix-suffix "x y z" "'" "[" "]")))
+ (should (string= result "['x', 'y', 'z']"))))
+
+(ert-deftest test-arrayify-only-prefix ()
+ "Should handle only prefix, no suffix."
+ (let ((result (test-arrayify-with-prefix-suffix "foo bar" "\"" "[" nil)))
+ (should (string= result "[\"foo\", \"bar\""))))
+
+(ert-deftest test-arrayify-only-suffix ()
+ "Should handle only suffix, no prefix."
+ (let ((result (test-arrayify-with-prefix-suffix "foo bar" "\"" nil "]")))
+ (should (string= result "\"foo\", \"bar\"]"))))
+
+(ert-deftest test-arrayify-multichar-prefix-suffix ()
+ "Should handle multi-character prefix/suffix."
+ (let ((result (test-arrayify-with-prefix-suffix "a b" "\"" "Array(" ")")))
+ (should (string= result "Array(\"a\", \"b\")"))))
+
+(ert-deftest test-arrayify-json-style ()
+ "Should create JSON-style array."
+ (let ((result (test-arrayify-with-prefix-suffix "apple banana" "\"" "[" "]")))
+ (should (string= result "[\"apple\", \"banana\"]"))))
+
+;;; Error Cases
+
+(ert-deftest test-arrayify-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "hello world")
+ (cj/--arrayify (point-max) (point-min) "\""))
+ :type 'error))
+
+(ert-deftest test-arrayify-empty-region ()
+ "Should handle empty region (start == end)."
+ (with-temp-buffer
+ (insert "hello world")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (should (string= "" (cj/--arrayify pos pos "\""))))))
+
+(ert-deftest test-arrayify-empty-region-with-brackets ()
+ "Should handle empty region with brackets."
+ (with-temp-buffer
+ (insert "hello world")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (should (string= "[]" (cj/--arrayify pos pos "\"" "[" "]"))))))
+
+(provide 'test-custom-ordering-arrayify)
+;;; test-custom-ordering-arrayify.el ends here
diff --git a/tests/test-custom-ordering-comma-to-lines.el b/tests/test-custom-ordering-comma-to-lines.el
new file mode 100644
index 00000000..93e37ec6
--- /dev/null
+++ b/tests/test-custom-ordering-comma-to-lines.el
@@ -0,0 +1,159 @@
+;;; test-custom-ordering-comma-to-lines.el --- Tests for cj/--comma-separated-text-to-lines -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--comma-separated-text-to-lines function from custom-ordering.el
+;;
+;; This function converts comma-separated text to separate lines.
+;; It replaces commas with newlines and removes trailing whitespace from each line.
+;;
+;; Examples:
+;; Input: "apple, banana, cherry"
+;; Output: "apple\nbanana\ncherry"
+;;
+;; Input: "one,two,three"
+;; Output: "one\ntwo\nthree"
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--comma-separated-text-to-lines)
+;; to avoid mocking region selection. This follows our testing best practice of
+;; separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-ordering)
+
+;;; Test Helpers
+
+(defun test-comma-to-lines (input-text)
+ "Test cj/--comma-separated-text-to-lines on INPUT-TEXT.
+Returns the transformed string."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--comma-separated-text-to-lines (point-min) (point-max))))
+
+;;; Normal Cases - Simple Comma-Separated
+
+(ert-deftest test-comma-to-lines-simple ()
+ "Should convert simple comma-separated text to lines."
+ (let ((result (test-comma-to-lines "apple, banana, cherry")))
+ (should (string= result "apple\n banana\n cherry"))))
+
+(ert-deftest test-comma-to-lines-no-spaces ()
+ "Should convert comma-separated text without spaces."
+ (let ((result (test-comma-to-lines "one,two,three")))
+ (should (string= result "one\ntwo\nthree"))))
+
+(ert-deftest test-comma-to-lines-two-elements ()
+ "Should convert two comma-separated elements."
+ (let ((result (test-comma-to-lines "hello,world")))
+ (should (string= result "hello\nworld"))))
+
+(ert-deftest test-comma-to-lines-with-varied-spacing ()
+ "Should preserve leading spaces after commas."
+ (let ((result (test-comma-to-lines "alpha, beta, gamma")))
+ (should (string= result "alpha\n beta\n gamma"))))
+
+;;; Normal Cases - Trailing Whitespace
+
+(ert-deftest test-comma-to-lines-trailing-spaces ()
+ "Should remove trailing spaces but preserve leading spaces."
+ (let ((result (test-comma-to-lines "apple , banana , cherry ")))
+ (should (string= result "apple\n banana\n cherry"))))
+
+(ert-deftest test-comma-to-lines-trailing-tabs ()
+ "Should remove trailing tabs after conversion."
+ (let ((result (test-comma-to-lines "apple\t,banana\t,cherry\t")))
+ (should (string= result "apple\nbanana\ncherry"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-comma-to-lines-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-comma-to-lines "")))
+ (should (string= result ""))))
+
+(ert-deftest test-comma-to-lines-single-element ()
+ "Should handle single element with no comma."
+ (let ((result (test-comma-to-lines "hello")))
+ (should (string= result "hello"))))
+
+(ert-deftest test-comma-to-lines-single-element-with-trailing-comma ()
+ "Should handle single element with trailing comma."
+ (let ((result (test-comma-to-lines "hello,")))
+ (should (string= result "hello\n"))))
+
+(ert-deftest test-comma-to-lines-leading-comma ()
+ "Should handle leading comma."
+ (let ((result (test-comma-to-lines ",apple,banana")))
+ (should (string= result "\napple\nbanana"))))
+
+(ert-deftest test-comma-to-lines-consecutive-commas ()
+ "Should handle consecutive commas."
+ (let ((result (test-comma-to-lines "apple,,banana")))
+ (should (string= result "apple\n\nbanana"))))
+
+(ert-deftest test-comma-to-lines-many-consecutive-commas ()
+ "Should handle many consecutive commas."
+ (let ((result (test-comma-to-lines "apple,,,banana")))
+ (should (string= result "apple\n\n\nbanana"))))
+
+(ert-deftest test-comma-to-lines-only-commas ()
+ "Should handle string with only commas (trailing blank lines removed)."
+ (let ((result (test-comma-to-lines ",,,")))
+ ;; delete-trailing-whitespace removes trailing blank lines
+ (should (string= result "\n"))))
+
+;;; Normal Cases - With Spaces Around Elements
+
+(ert-deftest test-comma-to-lines-leading-spaces ()
+ "Should preserve leading spaces within elements."
+ (let ((result (test-comma-to-lines " apple, banana, cherry")))
+ (should (string= result " apple\n banana\n cherry"))))
+
+(ert-deftest test-comma-to-lines-mixed-content ()
+ "Should handle mixed alphanumeric content."
+ (let ((result (test-comma-to-lines "item1,item2,item3")))
+ (should (string= result "item1\nitem2\nitem3"))))
+
+(ert-deftest test-comma-to-lines-with-numbers ()
+ "Should handle numbers."
+ (let ((result (test-comma-to-lines "1,2,3,4,5")))
+ (should (string= result "1\n2\n3\n4\n5"))))
+
+(ert-deftest test-comma-to-lines-very-long-list ()
+ "Should handle very long list."
+ (let* ((elements (mapcar #'number-to-string (number-sequence 1 100)))
+ (input (mapconcat #'identity elements ","))
+ (result (test-comma-to-lines input))
+ (lines (split-string result "\n")))
+ (should (= 100 (length lines)))))
+
+;;; Error Cases
+
+(ert-deftest test-comma-to-lines-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "a,b,c")
+ (cj/--comma-separated-text-to-lines (point-max) (point-min)))
+ :type 'error))
+
+(ert-deftest test-comma-to-lines-empty-region ()
+ "Should handle empty region (start == end)."
+ (with-temp-buffer
+ (insert "a,b,c")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (should (string= "" (cj/--comma-separated-text-to-lines pos pos))))))
+
+(provide 'test-custom-ordering-comma-to-lines)
+;;; test-custom-ordering-comma-to-lines.el ends here
diff --git a/tests/test-custom-ordering-number-lines.el b/tests/test-custom-ordering-number-lines.el
new file mode 100644
index 00000000..adda84f0
--- /dev/null
+++ b/tests/test-custom-ordering-number-lines.el
@@ -0,0 +1,181 @@
+;;; test-custom-ordering-number-lines.el --- Tests for cj/--number-lines -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--number-lines function from custom-ordering.el
+;;
+;; This function numbers lines in a region with a customizable format.
+;; The format string uses "N" as a placeholder for the line number.
+;; Optionally supports zero-padding for alignment.
+;;
+;; Examples:
+;; Input: "apple\nbanana\ncherry"
+;; Format: "N. "
+;; Output: "1. apple\n2. banana\n3. cherry"
+;;
+;; With zero-padding and 100 lines:
+;; "001. line\n002. line\n...\n100. line"
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--number-lines) to avoid
+;; mocking user input. This follows our testing best practice of
+;; separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+(require 'cl-lib)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-ordering)
+
+;;; Test Helpers
+
+(defun test-number-lines (input-text format-string zero-pad)
+ "Test cj/--number-lines on INPUT-TEXT.
+FORMAT-STRING is the format template.
+ZERO-PAD enables zero-padding.
+Returns the transformed string."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--number-lines (point-min) (point-max) format-string zero-pad)))
+
+;;; Normal Cases - Standard Format "N. "
+
+(ert-deftest test-number-lines-standard-format ()
+ "Should number lines with standard format."
+ (let ((result (test-number-lines "apple\nbanana\ncherry" "N. " nil)))
+ (should (string= result "1. apple\n2. banana\n3. cherry"))))
+
+(ert-deftest test-number-lines-two-lines ()
+ "Should number two lines."
+ (let ((result (test-number-lines "first\nsecond" "N. " nil)))
+ (should (string= result "1. first\n2. second"))))
+
+(ert-deftest test-number-lines-single-line ()
+ "Should number single line."
+ (let ((result (test-number-lines "only" "N. " nil)))
+ (should (string= result "1. only"))))
+
+;;; Normal Cases - Alternative Formats
+
+(ert-deftest test-number-lines-parenthesis-format ()
+ "Should number with parenthesis format."
+ (let ((result (test-number-lines "a\nb\nc" "N) " nil)))
+ (should (string= result "1) a\n2) b\n3) c"))))
+
+(ert-deftest test-number-lines-bracket-format ()
+ "Should number with bracket format."
+ (let ((result (test-number-lines "x\ny\nz" "[N] " nil)))
+ (should (string= result "[1] x\n[2] y\n[3] z"))))
+
+(ert-deftest test-number-lines-no-space-format ()
+ "Should number without space."
+ (let ((result (test-number-lines "a\nb" "N." nil)))
+ (should (string= result "1.a\n2.b"))))
+
+(ert-deftest test-number-lines-custom-format ()
+ "Should number with custom format."
+ (let ((result (test-number-lines "foo\nbar" "Item N: " nil)))
+ (should (string= result "Item 1: foo\nItem 2: bar"))))
+
+;;; Normal Cases - Zero Padding
+
+(ert-deftest test-number-lines-zero-pad-single-digit ()
+ "Should not pad when max is single digit."
+ (let ((result (test-number-lines "a\nb\nc" "N. " t)))
+ (should (string= result "1. a\n2. b\n3. c"))))
+
+(ert-deftest test-number-lines-zero-pad-double-digit ()
+ "Should pad to 2 digits when max is 10-99."
+ (let* ((lines (make-list 12 "line"))
+ (input (mapconcat #'identity lines "\n"))
+ (result (test-number-lines input "N. " t))
+ (result-lines (split-string result "\n")))
+ (should (string-prefix-p "01. " (nth 0 result-lines)))
+ (should (string-prefix-p "09. " (nth 8 result-lines)))
+ (should (string-prefix-p "10. " (nth 9 result-lines)))
+ (should (string-prefix-p "12. " (nth 11 result-lines)))))
+
+(ert-deftest test-number-lines-zero-pad-triple-digit ()
+ "Should pad to 3 digits when max is 100+."
+ (let* ((lines (make-list 105 "x"))
+ (input (mapconcat #'identity lines "\n"))
+ (result (test-number-lines input "N. " t))
+ (result-lines (split-string result "\n")))
+ (should (string-prefix-p "001. " (nth 0 result-lines)))
+ (should (string-prefix-p "099. " (nth 98 result-lines)))
+ (should (string-prefix-p "100. " (nth 99 result-lines)))
+ (should (string-prefix-p "105. " (nth 104 result-lines)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-number-lines-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-number-lines "" "N. " nil)))
+ (should (string= result "1. "))))
+
+(ert-deftest test-number-lines-empty-lines ()
+ "Should number empty lines."
+ (let ((result (test-number-lines "\n\n" "N. " nil)))
+ (should (string= result "1. \n2. \n3. "))))
+
+(ert-deftest test-number-lines-with-existing-numbers ()
+ "Should number lines that already have content."
+ (let ((result (test-number-lines "1. old\n2. old" "N. " nil)))
+ (should (string= result "1. 1. old\n2. 2. old"))))
+
+(ert-deftest test-number-lines-multiple-N-in-format ()
+ "Should replace multiple N occurrences."
+ (let ((result (test-number-lines "a\nb" "N-N. " nil)))
+ (should (string= result "1-1. a\n2-2. b"))))
+
+(ert-deftest test-number-lines-long-content ()
+ "Should number lines with long content."
+ (let* ((long-line (make-string 100 ?x))
+ (input (format "%s\n%s" long-line long-line))
+ (result (test-number-lines input "N. " nil)))
+ (should (string-prefix-p "1. " result))
+ (should (string-match "2\\. " result))))
+
+;;; Normal Cases - No Zero Padding vs Zero Padding
+
+(ert-deftest test-number-lines-comparison-no-pad-vs-pad ()
+ "Should show difference between no padding and padding."
+ (let* ((input "a\nb\nc\nd\ne\nf\ng\nh\ni\nj")
+ (no-pad (test-number-lines input "N. " nil))
+ (with-pad (test-number-lines input "N. " t))
+ (no-pad-lines (split-string no-pad "\n"))
+ (with-pad-lines (split-string with-pad "\n")))
+ ;; Without padding: "1. ", "10. "
+ (should (string-prefix-p "1. " (nth 0 no-pad-lines)))
+ (should (string-prefix-p "10. " (nth 9 no-pad-lines)))
+ ;; With padding: "01. ", "10. "
+ (should (string-prefix-p "01. " (nth 0 with-pad-lines)))
+ (should (string-prefix-p "10. " (nth 9 with-pad-lines)))))
+
+;;; Error Cases
+
+(ert-deftest test-number-lines-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "line1\nline2")
+ (cj/--number-lines (point-max) (point-min) "N. " nil))
+ :type 'error))
+
+(ert-deftest test-number-lines-empty-region ()
+ "Should handle empty region (start == end)."
+ (with-temp-buffer
+ (insert "line1\nline2")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (should (string= "1. " (cj/--number-lines pos pos "N. " nil))))))
+
+(provide 'test-custom-ordering-number-lines)
+;;; test-custom-ordering-number-lines.el ends here
diff --git a/tests/test-custom-ordering-reverse-lines.el b/tests/test-custom-ordering-reverse-lines.el
new file mode 100644
index 00000000..3c71362d
--- /dev/null
+++ b/tests/test-custom-ordering-reverse-lines.el
@@ -0,0 +1,131 @@
+;;; test-custom-ordering-reverse-lines.el --- Tests for cj/--reverse-lines -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--reverse-lines function from custom-ordering.el
+;;
+;; This function reverses the order of lines in a region.
+;; The first line becomes last, last becomes first, etc.
+;;
+;; Examples:
+;; Input: "line1\nline2\nline3"
+;; Output: "line3\nline2\nline1"
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--reverse-lines) to avoid
+;; mocking region selection. This follows our testing best practice of
+;; separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-ordering)
+
+;;; Test Helpers
+
+(defun test-reverse-lines (input-text)
+ "Test cj/--reverse-lines on INPUT-TEXT.
+Returns the transformed string."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--reverse-lines (point-min) (point-max))))
+
+;;; Normal Cases
+
+(ert-deftest test-reverse-lines-three-lines ()
+ "Should reverse three lines."
+ (let ((result (test-reverse-lines "line1\nline2\nline3")))
+ (should (string= result "line3\nline2\nline1"))))
+
+(ert-deftest test-reverse-lines-two-lines ()
+ "Should reverse two lines."
+ (let ((result (test-reverse-lines "first\nsecond")))
+ (should (string= result "second\nfirst"))))
+
+(ert-deftest test-reverse-lines-many-lines ()
+ "Should reverse many lines."
+ (let ((result (test-reverse-lines "a\nb\nc\nd\ne")))
+ (should (string= result "e\nd\nc\nb\na"))))
+
+(ert-deftest test-reverse-lines-with-content ()
+ "Should reverse lines with actual content."
+ (let ((result (test-reverse-lines "apple banana\ncherry date\negg fig")))
+ (should (string= result "egg fig\ncherry date\napple banana"))))
+
+(ert-deftest test-reverse-lines-bidirectional ()
+ "Should reverse back and forth correctly."
+ (let* ((original "line1\nline2\nline3")
+ (reversed (test-reverse-lines original))
+ (back (test-reverse-lines reversed)))
+ (should (string= reversed "line3\nline2\nline1"))
+ (should (string= back original))))
+
+;;; Boundary Cases
+
+(ert-deftest test-reverse-lines-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-reverse-lines "")))
+ (should (string= result ""))))
+
+(ert-deftest test-reverse-lines-single-line ()
+ "Should handle single line (no change)."
+ (let ((result (test-reverse-lines "single line")))
+ (should (string= result "single line"))))
+
+(ert-deftest test-reverse-lines-empty-lines ()
+ "Should reverse including empty lines."
+ (let ((result (test-reverse-lines "a\n\nb")))
+ (should (string= result "b\n\na"))))
+
+(ert-deftest test-reverse-lines-trailing-newline ()
+ "Should handle trailing newline."
+ (let ((result (test-reverse-lines "line1\nline2\n")))
+ (should (string= result "\nline2\nline1"))))
+
+(ert-deftest test-reverse-lines-only-newlines ()
+ "Should reverse lines that are only newlines."
+ (let ((result (test-reverse-lines "\n\n\n")))
+ (should (string= result "\n\n\n"))))
+
+(ert-deftest test-reverse-lines-numbers ()
+ "Should reverse numbered lines."
+ (let ((result (test-reverse-lines "1\n2\n3\n4\n5")))
+ (should (string= result "5\n4\n3\n2\n1"))))
+
+(ert-deftest test-reverse-lines-very-long ()
+ "Should reverse very long list."
+ (let* ((lines (mapcar #'number-to-string (number-sequence 1 100)))
+ (input (mapconcat #'identity lines "\n"))
+ (result (test-reverse-lines input))
+ (result-lines (split-string result "\n")))
+ (should (= 100 (length result-lines)))
+ (should (string= "100" (car result-lines)))
+ (should (string= "1" (car (last result-lines))))))
+
+;;; Error Cases
+
+(ert-deftest test-reverse-lines-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "line1\nline2")
+ (cj/--reverse-lines (point-max) (point-min)))
+ :type 'error))
+
+(ert-deftest test-reverse-lines-empty-region ()
+ "Should handle empty region (start == end)."
+ (with-temp-buffer
+ (insert "line1\nline2")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (should (string= "" (cj/--reverse-lines pos pos))))))
+
+(provide 'test-custom-ordering-reverse-lines)
+;;; test-custom-ordering-reverse-lines.el ends here
diff --git a/tests/test-custom-ordering-toggle-quotes.el b/tests/test-custom-ordering-toggle-quotes.el
new file mode 100644
index 00000000..e11305ee
--- /dev/null
+++ b/tests/test-custom-ordering-toggle-quotes.el
@@ -0,0 +1,155 @@
+;;; test-custom-ordering-toggle-quotes.el --- Tests for cj/--toggle-quotes -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--toggle-quotes function from custom-ordering.el
+;;
+;; This function toggles between double quotes and single quotes.
+;; All " become ' and all ' become ".
+;;
+;; Examples:
+;; Input: "apple", "banana"
+;; Output: 'apple', 'banana'
+;;
+;; Input: 'hello', 'world'
+;; Output: "hello", "world"
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--toggle-quotes) to avoid
+;; mocking region selection. This follows our testing best practice of
+;; separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-ordering)
+
+;;; Test Helpers
+
+(defun test-toggle-quotes (input-text)
+ "Test cj/--toggle-quotes on INPUT-TEXT.
+Returns the transformed string."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--toggle-quotes (point-min) (point-max))))
+
+;;; Normal Cases - Double to Single
+
+(ert-deftest test-toggle-quotes-double-to-single ()
+ "Should convert double quotes to single quotes."
+ (let ((result (test-toggle-quotes "\"apple\", \"banana\"")))
+ (should (string= result "'apple', 'banana'"))))
+
+(ert-deftest test-toggle-quotes-single-double-quote ()
+ "Should convert single double quote."
+ (let ((result (test-toggle-quotes "\"")))
+ (should (string= result "'"))))
+
+(ert-deftest test-toggle-quotes-multiple-double-quotes ()
+ "Should convert multiple double quotes."
+ (let ((result (test-toggle-quotes "\"hello\" \"world\" \"test\"")))
+ (should (string= result "'hello' 'world' 'test'"))))
+
+;;; Normal Cases - Single to Double
+
+(ert-deftest test-toggle-quotes-single-to-double ()
+ "Should convert single quotes to double quotes."
+ (let ((result (test-toggle-quotes "'apple', 'banana'")))
+ (should (string= result "\"apple\", \"banana\""))))
+
+(ert-deftest test-toggle-quotes-single-single-quote ()
+ "Should convert single single quote."
+ (let ((result (test-toggle-quotes "'")))
+ (should (string= result "\""))))
+
+(ert-deftest test-toggle-quotes-multiple-single-quotes ()
+ "Should convert multiple single quotes."
+ (let ((result (test-toggle-quotes "'hello' 'world' 'test'")))
+ (should (string= result "\"hello\" \"world\" \"test\""))))
+
+;;; Normal Cases - Mixed Quotes
+
+(ert-deftest test-toggle-quotes-mixed ()
+ "Should toggle mixed quotes."
+ (let ((result (test-toggle-quotes "\"double\" 'single'")))
+ (should (string= result "'double' \"single\""))))
+
+(ert-deftest test-toggle-quotes-bidirectional ()
+ "Should toggle back and forth correctly."
+ (let* ((original "\"apple\", \"banana\"")
+ (toggled (test-toggle-quotes original))
+ (back (test-toggle-quotes toggled)))
+ (should (string= toggled "'apple', 'banana'"))
+ (should (string= back original))))
+
+;;; Normal Cases - With Text Content
+
+(ert-deftest test-toggle-quotes-preserves-content ()
+ "Should preserve content while toggling quotes."
+ (let ((result (test-toggle-quotes "var x = \"hello world\";")))
+ (should (string= result "var x = 'hello world';"))))
+
+(ert-deftest test-toggle-quotes-sql-style ()
+ "Should toggle SQL-style quotes."
+ (let ((result (test-toggle-quotes "SELECT * FROM users WHERE name='John'")))
+ (should (string= result "SELECT * FROM users WHERE name=\"John\""))))
+
+(ert-deftest test-toggle-quotes-multiline ()
+ "Should toggle quotes across multiple lines."
+ (let ((result (test-toggle-quotes "\"line1\"\n\"line2\"\n\"line3\"")))
+ (should (string= result "'line1'\n'line2'\n'line3'"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-toggle-quotes-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-toggle-quotes "")))
+ (should (string= result ""))))
+
+(ert-deftest test-toggle-quotes-no-quotes ()
+ "Should handle text with no quotes."
+ (let ((result (test-toggle-quotes "hello world")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-toggle-quotes-only-double-quotes ()
+ "Should handle string with only double quotes."
+ (let ((result (test-toggle-quotes "\"\"\"\"")))
+ (should (string= result "''''"))))
+
+(ert-deftest test-toggle-quotes-only-single-quotes ()
+ "Should handle string with only single quotes."
+ (let ((result (test-toggle-quotes "''''")))
+ (should (string= result "\"\"\"\""))))
+
+(ert-deftest test-toggle-quotes-adjacent-quotes ()
+ "Should handle adjacent quotes."
+ (let ((result (test-toggle-quotes "\"\"''")))
+ (should (string= result "''\"\""))))
+
+;;; Error Cases
+
+(ert-deftest test-toggle-quotes-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "\"hello\"")
+ (cj/--toggle-quotes (point-max) (point-min)))
+ :type 'error))
+
+(ert-deftest test-toggle-quotes-empty-region ()
+ "Should handle empty region (start == end)."
+ (with-temp-buffer
+ (insert "\"hello\"")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (should (string= "" (cj/--toggle-quotes pos pos))))))
+
+(provide 'test-custom-ordering-toggle-quotes)
+;;; test-custom-ordering-toggle-quotes.el ends here
diff --git a/tests/test-custom-ordering-unarrayify.el b/tests/test-custom-ordering-unarrayify.el
new file mode 100644
index 00000000..a778f419
--- /dev/null
+++ b/tests/test-custom-ordering-unarrayify.el
@@ -0,0 +1,159 @@
+;;; test-custom-ordering-unarrayify.el --- Tests for cj/--unarrayify -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--unarrayify function from custom-ordering.el
+;;
+;; This function converts comma-separated array format back to separate lines.
+;; It splits by ", " (comma-space), removes quotes (both " and '), and joins with newlines.
+;;
+;; Examples:
+;; Input: "\"apple\", \"banana\", \"cherry\""
+;; Output: "apple\nbanana\ncherry"
+;;
+;; Input: "'one', 'two', 'three'"
+;; Output: "one\ntwo\nthree"
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--unarrayify) to avoid
+;; mocking region selection. This follows our testing best practice of
+;; separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-ordering)
+
+;;; Test Helpers
+
+(defun test-unarrayify (input-text)
+ "Test cj/--unarrayify on INPUT-TEXT.
+Returns the transformed string."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--unarrayify (point-min) (point-max))))
+
+;;; Normal Cases - Double Quotes
+
+(ert-deftest test-unarrayify-double-quotes-simple ()
+ "Should unarrayify double-quoted elements."
+ (let ((result (test-unarrayify "\"apple\", \"banana\", \"cherry\"")))
+ (should (string= result "apple\nbanana\ncherry"))))
+
+(ert-deftest test-unarrayify-double-quotes-single-element ()
+ "Should unarrayify single double-quoted element."
+ (let ((result (test-unarrayify "\"hello\"")))
+ (should (string= result "hello"))))
+
+(ert-deftest test-unarrayify-double-quotes-two-elements ()
+ "Should unarrayify two double-quoted elements."
+ (let ((result (test-unarrayify "\"one\", \"two\"")))
+ (should (string= result "one\ntwo"))))
+
+;;; Normal Cases - Single Quotes
+
+(ert-deftest test-unarrayify-single-quotes-simple ()
+ "Should unarrayify single-quoted elements."
+ (let ((result (test-unarrayify "'alpha', 'beta', 'gamma'")))
+ (should (string= result "alpha\nbeta\ngamma"))))
+
+(ert-deftest test-unarrayify-single-quotes-single-element ()
+ "Should unarrayify single single-quoted element."
+ (let ((result (test-unarrayify "'hello'")))
+ (should (string= result "hello"))))
+
+;;; Normal Cases - Mixed Quotes
+
+(ert-deftest test-unarrayify-mixed-quotes ()
+ "Should unarrayify mixed quote types."
+ (let ((result (test-unarrayify "\"apple\", 'banana', \"cherry\"")))
+ (should (string= result "apple\nbanana\ncherry"))))
+
+;;; Normal Cases - No Quotes
+
+(ert-deftest test-unarrayify-no-quotes ()
+ "Should unarrayify unquoted elements."
+ (let ((result (test-unarrayify "foo, bar, baz")))
+ (should (string= result "foo\nbar\nbaz"))))
+
+;;; Normal Cases - Various Content
+
+(ert-deftest test-unarrayify-with-numbers ()
+ "Should unarrayify numbers."
+ (let ((result (test-unarrayify "\"1\", \"2\", \"3\"")))
+ (should (string= result "1\n2\n3"))))
+
+(ert-deftest test-unarrayify-with-spaces-in-elements ()
+ "Should preserve spaces within elements."
+ (let ((result (test-unarrayify "\"hello world\", \"foo bar\"")))
+ (should (string= result "hello world\nfoo bar"))))
+
+(ert-deftest test-unarrayify-mixed-content ()
+ "Should unarrayify mixed alphanumeric content."
+ (let ((result (test-unarrayify "\"item1\", \"item2\", \"item3\"")))
+ (should (string= result "item1\nitem2\nitem3"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-unarrayify-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-unarrayify "")))
+ (should (string= result ""))))
+
+(ert-deftest test-unarrayify-only-quotes ()
+ "Should remove quotes from quote-only string."
+ (let ((result (test-unarrayify "\"\"")))
+ (should (string= result ""))))
+
+(ert-deftest test-unarrayify-very-long-list ()
+ "Should handle very long list."
+ (let* ((elements (mapcar (lambda (i) (format "\"%d\"" i)) (number-sequence 1 100)))
+ (input (mapconcat #'identity elements ", "))
+ (result (test-unarrayify input))
+ (lines (split-string result "\n")))
+ (should (= 100 (length lines)))))
+
+(ert-deftest test-unarrayify-with-empty-elements ()
+ "Should handle empty quoted elements."
+ (let ((result (test-unarrayify "\"\", \"test\", \"\"")))
+ (should (string= result "\ntest\n"))))
+
+;;; Edge Cases - Nested or Mismatched Quotes
+
+(ert-deftest test-unarrayify-double-quotes-in-single ()
+ "Should handle double quotes inside single-quoted strings."
+ (let ((result (test-unarrayify "'he said \"hello\"', 'world'")))
+ (should (string= result "he said hello\nworld"))))
+
+(ert-deftest test-unarrayify-only-opening-quotes ()
+ "Should remove all quote characters even if mismatched."
+ (let ((result (test-unarrayify "\"apple, \"banana, \"cherry")))
+ (should (string= result "apple\nbanana\ncherry"))))
+
+;;; Error Cases
+
+(ert-deftest test-unarrayify-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "\"a\", \"b\"")
+ (cj/--unarrayify (point-max) (point-min)))
+ :type 'error))
+
+(ert-deftest test-unarrayify-empty-region ()
+ "Should handle empty region (start == end)."
+ (with-temp-buffer
+ (insert "\"a\", \"b\"")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (should (string= "" (cj/--unarrayify pos pos))))))
+
+(provide 'test-custom-ordering-unarrayify)
+;;; test-custom-ordering-unarrayify.el ends here
diff --git a/tests/test-custom-org-agenda-functions.el.disabled b/tests/test-custom-org-agenda-functions.el.disabled
deleted file mode 100644
index 44f9f43d..00000000
--- a/tests/test-custom-org-agenda-functions.el.disabled
+++ /dev/null
@@ -1,94 +0,0 @@
-;;; test-custom-org-agenda-functions.el --- Tests for custom functions in org-agenda -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; This tests the custom functions created to build the main agenda in org-agenda-config.el
-
-;;; Code:
-
-(add-to-list 'load-path (concat user-emacs-directory "modules"))
-(require 'org-agenda-config)
-
-(ert-deftest test-cj/org-skip-subtree-if-habit-positive ()
- (with-temp-buffer
- (insert "* TODO [#A] Test task\n")
- (insert ":PROPERTIES:\n")
- (insert ":STYLE: habit\n")
- (insert ":RESET_CHECK_BOXES: t\n")
- (insert ":END:\n")
- (org-mode)
- (goto-char (point-min))
- (should (not (eq nil (cj/org-skip-subtree-if-habit))))))
-
-(ert-deftest test-cj/org-skip-subtree-if-habit-negative ()
- (with-temp-buffer
- (insert "* TODO [#A] Test task\n")
- (org-mode)
- (goto-char (point-min))
- (should (eq nil (cj/org-skip-subtree-if-habit)))))
-
-(ert-deftest test-cj/org-skip-subtree-if-priority-positive ()
- (with-temp-buffer
- (insert "* TODO [#A] Test task\n")
- (org-mode)
- (goto-char (point-min))
- (should (not (eq nil (cj/org-skip-subtree-if-priority ?A))))))
-
-(ert-deftest test-cj/org-skip-subtree-if-priority-negative ()
- (erase-buffer)
- (insert "* TODO [#B] Test task\n")
- (org-mode)
- (goto-char (point-min))
- (should (eq nil (cj/org-skip-subtree-if-priority ?A))))
-
-(ert-deftest test-cj/org-skip-subtree-if-priority-boundary0 ()
- (erase-buffer)
- (insert "* TODO Test task\n")
- (org-mode)
- (goto-char (point-min))
- (should (eq nil (cj/org-skip-subtree-if-priority ?A))))
-
-(ert-deftest test-cj/org-skip-subtree-if-priority-boundary1 ()
- (erase-buffer)
- (insert "* Test entry\n")
- (org-mode)
- (goto-char (point-min))
- (should (eq nil (cj/org-skip-subtree-if-priority ?A))))
-
-(ert-deftest test-cj/org-skip-subtree-if-keyword-positive ()
- (with-temp-buffer
- (insert "* TODO [#A] Test task\n")
- (org-mode)
- (goto-char (point-min))
- (should (not (eq nil (cj/org-skip-subtree-if-keyword '("TODO")))))))
-
-(ert-deftest test-cj/org-skip-subtree-if-keyword-positive-multiple ()
- (with-temp-buffer
- (insert "* PROJECT Test entry\n")
- (org-mode)
- (goto-char (point-min))
- (should (not (eq nil (cj/org-skip-subtree-if-keyword '("TODO" "PROJECT")))))))
-
-(ert-deftest test-cj/org-skip-subtree-if-keyword-negative ()
- (erase-buffer)
- (insert "* PROJECT [#A] Test task\n")
- (org-mode)
- (goto-char (point-min))
- (should (eq nil (cj/org-skip-subtree-if-keyword '("TODO")))))
-
-(ert-deftest test-cj/org-skip-subtree-if-keyword-negative-superset ()
- (erase-buffer)
- (insert "* PROJECT [#A] Test task\n")
- (org-mode)
- (goto-char (point-min))
- (should (eq nil (cj/org-skip-subtree-if-keyword '("TODOTODO")))))
-
-(ert-deftest test-cj/org-skip-subtree-if-keyword-negative-multiple ()
- (erase-buffer)
- (insert "* PROJECT [#A] Test task\n")
- (org-mode)
- (goto-char (point-min))
- (should (eq nil (cj/org-skip-subtree-if-keyword '("TODO" "DONE")))))
-
-
-(provide 'test-custom-org-agenda-functions)
-;;; test-custom-org-agenda-functions.el ends here.
diff --git a/tests/test-custom-text-enclose-append.el b/tests/test-custom-text-enclose-append.el
new file mode 100644
index 00000000..3593a7f5
--- /dev/null
+++ b/tests/test-custom-text-enclose-append.el
@@ -0,0 +1,190 @@
+;;; test-custom-text-enclose-append.el --- Tests for cj/--append-to-lines -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--append-to-lines function from custom-text-enclose.el
+;;
+;; This function appends a suffix string to the end of each line in text.
+;; It preserves the structure of lines and handles trailing newlines correctly.
+;;
+;; Examples:
+;; Input: "line1\nline2", suffix: ";"
+;; Output: "line1;\nline2;"
+;;
+;; Input: "single", suffix: "!"
+;; Output: "single!"
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--append-to-lines) to avoid
+;; mocking region selection. This follows our testing best practice of
+;; separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-text-enclose)
+
+;;; Test Helpers
+
+(defun test-append-to-lines (text suffix)
+ "Test cj/--append-to-lines on TEXT with SUFFIX.
+Returns the transformed string."
+ (cj/--append-to-lines text suffix))
+
+;;; Normal Cases - Single Line
+
+(ert-deftest test-append-single-line ()
+ "Should append to single line."
+ (let ((result (test-append-to-lines "hello" ";")))
+ (should (string= result "hello;"))))
+
+(ert-deftest test-append-single-line-semicolon ()
+ "Should append semicolon to single line."
+ (let ((result (test-append-to-lines "var x = 5" ";")))
+ (should (string= result "var x = 5;"))))
+
+(ert-deftest test-append-single-line-exclamation ()
+ "Should append exclamation mark to single line."
+ (let ((result (test-append-to-lines "Hello world" "!")))
+ (should (string= result "Hello world!"))))
+
+;;; Normal Cases - Multiple Lines
+
+(ert-deftest test-append-two-lines ()
+ "Should append to two lines."
+ (let ((result (test-append-to-lines "line1\nline2" ";")))
+ (should (string= result "line1;\nline2;"))))
+
+(ert-deftest test-append-three-lines ()
+ "Should append to three lines."
+ (let ((result (test-append-to-lines "a\nb\nc" ".")))
+ (should (string= result "a.\nb.\nc."))))
+
+(ert-deftest test-append-many-lines ()
+ "Should append to many lines."
+ (let* ((lines (make-list 10 "line"))
+ (input (mapconcat #'identity lines "\n"))
+ (result (test-append-to-lines input ";"))
+ (result-lines (split-string result "\n")))
+ (should (= 10 (length result-lines)))
+ (should (cl-every (lambda (line) (string-suffix-p ";" line)) result-lines))))
+
+;;; Normal Cases - Various Suffixes
+
+(ert-deftest test-append-comma ()
+ "Should append comma to lines."
+ (let ((result (test-append-to-lines "apple\nbanana" ",")))
+ (should (string= result "apple,\nbanana,"))))
+
+(ert-deftest test-append-multi-char ()
+ "Should append multi-character suffix."
+ (let ((result (test-append-to-lines "line" " // comment")))
+ (should (string= result "line // comment"))))
+
+(ert-deftest test-append-pipe ()
+ "Should append pipe character."
+ (let ((result (test-append-to-lines "col1\ncol2" " |")))
+ (should (string= result "col1 |\ncol2 |"))))
+
+(ert-deftest test-append-empty-suffix ()
+ "Should handle empty suffix."
+ (let ((result (test-append-to-lines "line1\nline2" "")))
+ (should (string= result "line1\nline2"))))
+
+;;; Boundary Cases - Trailing Newlines
+
+(ert-deftest test-append-with-trailing-newline ()
+ "Should preserve trailing newline."
+ (let ((result (test-append-to-lines "line1\nline2\n" ";")))
+ (should (string= result "line1;\nline2;\n"))))
+
+(ert-deftest test-append-no-trailing-newline ()
+ "Should work without trailing newline."
+ (let ((result (test-append-to-lines "line1\nline2" ";")))
+ (should (string= result "line1;\nline2;"))))
+
+(ert-deftest test-append-single-line-with-newline ()
+ "Should preserve trailing newline on single line."
+ (let ((result (test-append-to-lines "line\n" ";")))
+ (should (string= result "line;\n"))))
+
+;;; Boundary Cases - Empty Lines
+
+(ert-deftest test-append-empty-line-between ()
+ "Should append to empty line between other lines."
+ (let ((result (test-append-to-lines "line1\n\nline3" ";")))
+ (should (string= result "line1;\n;\nline3;"))))
+
+(ert-deftest test-append-only-empty-lines ()
+ "Should append to only empty lines."
+ (let ((result (test-append-to-lines "\n\n" ";")))
+ (should (string= result ";\n;\n"))))
+
+(ert-deftest test-append-empty-first-line ()
+ "Should append to empty first line."
+ (let ((result (test-append-to-lines "\nline2\nline3" ";")))
+ (should (string= result ";\nline2;\nline3;"))))
+
+;;; Boundary Cases - Whitespace
+
+(ert-deftest test-append-preserves-leading-whitespace ()
+ "Should preserve leading whitespace."
+ (let ((result (test-append-to-lines " line1\n line2" ";")))
+ (should (string= result " line1;\n line2;"))))
+
+(ert-deftest test-append-preserves-trailing-whitespace ()
+ "Should preserve trailing whitespace on line."
+ (let ((result (test-append-to-lines "line1 \nline2 " ";")))
+ (should (string= result "line1 ;\nline2 ;"))))
+
+(ert-deftest test-append-whitespace-only-line ()
+ "Should append to whitespace-only line."
+ (let ((result (test-append-to-lines "line1\n \nline3" ";")))
+ (should (string= result "line1;\n ;\nline3;"))))
+
+;;; Boundary Cases - Special Cases
+
+(ert-deftest test-append-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-append-to-lines "" ";")))
+ (should (string= result ";"))))
+
+(ert-deftest test-append-very-long-line ()
+ "Should append to very long line."
+ (let* ((long-line (make-string 1000 ?a))
+ (result (test-append-to-lines long-line ";")))
+ (should (string-suffix-p ";" result))
+ (should (= (length result) 1001))))
+
+(ert-deftest test-append-with-existing-suffix ()
+ "Should append even if line already has the suffix."
+ (let ((result (test-append-to-lines "line;" ";")))
+ (should (string= result "line;;"))))
+
+;;; Edge Cases - Special Characters in Suffix
+
+(ert-deftest test-append-newline-suffix ()
+ "Should append newline as suffix."
+ (let ((result (test-append-to-lines "line1\nline2" "\n")))
+ (should (string= result "line1\n\nline2\n"))))
+
+(ert-deftest test-append-tab-suffix ()
+ "Should append tab as suffix."
+ (let ((result (test-append-to-lines "col1\ncol2" "\t")))
+ (should (string= result "col1\t\ncol2\t"))))
+
+(ert-deftest test-append-quote-suffix ()
+ "Should append quote as suffix."
+ (let ((result (test-append-to-lines "value1\nvalue2" "\"")))
+ (should (string= result "value1\"\nvalue2\""))))
+
+(provide 'test-custom-text-enclose-append)
+;;; test-custom-text-enclose-append.el ends here
diff --git a/tests/test-custom-text-enclose-indent.el b/tests/test-custom-text-enclose-indent.el
new file mode 100644
index 00000000..e9042d35
--- /dev/null
+++ b/tests/test-custom-text-enclose-indent.el
@@ -0,0 +1,241 @@
+;;; test-custom-text-enclose-indent.el --- Tests for cj/--indent-lines and cj/--dedent-lines -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--indent-lines and cj/--dedent-lines functions from custom-text-enclose.el
+;;
+;; cj/--indent-lines adds leading whitespace (spaces or tabs) to each line.
+;; cj/--dedent-lines removes up to COUNT leading whitespace characters from each line.
+;;
+;; Examples (indent):
+;; Input: "line1\nline2", count: 4, use-tabs: nil
+;; Output: " line1\n line2"
+;;
+;; Examples (dedent):
+;; Input: " line1\n line2", count: 4
+;; Output: "line1\nline2"
+;;
+;; We test the NON-INTERACTIVE implementations to avoid mocking user input.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-text-enclose)
+
+;;; Test Helpers
+
+(defun test-indent (text count use-tabs)
+ "Test cj/--indent-lines on TEXT with COUNT and USE-TABS.
+Returns the transformed string."
+ (cj/--indent-lines text count use-tabs))
+
+(defun test-dedent (text count)
+ "Test cj/--dedent-lines on TEXT with COUNT.
+Returns the transformed string."
+ (cj/--dedent-lines text count))
+
+;;; Indent Tests - Normal Cases with Spaces
+
+(ert-deftest test-indent-single-line-4-spaces ()
+ "Should indent single line with 4 spaces."
+ (let ((result (test-indent "line" 4 nil)))
+ (should (string= result " line"))))
+
+(ert-deftest test-indent-two-lines-4-spaces ()
+ "Should indent two lines with 4 spaces."
+ (let ((result (test-indent "line1\nline2" 4 nil)))
+ (should (string= result " line1\n line2"))))
+
+(ert-deftest test-indent-three-lines-2-spaces ()
+ "Should indent three lines with 2 spaces."
+ (let ((result (test-indent "a\nb\nc" 2 nil)))
+ (should (string= result " a\n b\n c"))))
+
+(ert-deftest test-indent-many-lines ()
+ "Should indent many lines."
+ (let ((result (test-indent "1\n2\n3\n4\n5" 4 nil)))
+ (should (string= result " 1\n 2\n 3\n 4\n 5"))))
+
+;;; Indent Tests - Normal Cases with Tabs
+
+(ert-deftest test-indent-single-line-1-tab ()
+ "Should indent single line with 1 tab."
+ (let ((result (test-indent "line" 1 t)))
+ (should (string= result "\tline"))))
+
+(ert-deftest test-indent-two-lines-1-tab ()
+ "Should indent two lines with 1 tab."
+ (let ((result (test-indent "line1\nline2" 1 t)))
+ (should (string= result "\tline1\n\tline2"))))
+
+(ert-deftest test-indent-with-2-tabs ()
+ "Should indent with 2 tabs."
+ (let ((result (test-indent "code" 2 t)))
+ (should (string= result "\t\tcode"))))
+
+;;; Indent Tests - Boundary Cases
+
+(ert-deftest test-indent-empty-string ()
+ "Should indent empty string."
+ (let ((result (test-indent "" 4 nil)))
+ (should (string= result " "))))
+
+(ert-deftest test-indent-zero-count ()
+ "Should not indent with count 0."
+ (let ((result (test-indent "line" 0 nil)))
+ (should (string= result "line"))))
+
+(ert-deftest test-indent-already-indented ()
+ "Should add more indentation to already indented lines."
+ (let ((result (test-indent " line1\n line2" 2 nil)))
+ (should (string= result " line1\n line2"))))
+
+(ert-deftest test-indent-empty-lines ()
+ "Should indent empty lines."
+ (let ((result (test-indent "line1\n\nline3" 4 nil)))
+ (should (string= result " line1\n \n line3"))))
+
+(ert-deftest test-indent-trailing-newline ()
+ "Should preserve trailing newline."
+ (let ((result (test-indent "line1\nline2\n" 4 nil)))
+ (should (string= result " line1\n line2\n"))))
+
+(ert-deftest test-indent-no-trailing-newline ()
+ "Should work without trailing newline."
+ (let ((result (test-indent "line1\nline2" 4 nil)))
+ (should (string= result " line1\n line2"))))
+
+;;; Dedent Tests - Normal Cases
+
+(ert-deftest test-dedent-single-line-4-spaces ()
+ "Should dedent single line with 4 spaces."
+ (let ((result (test-dedent " line" 4)))
+ (should (string= result "line"))))
+
+(ert-deftest test-dedent-two-lines-4-spaces ()
+ "Should dedent two lines with 4 spaces."
+ (let ((result (test-dedent " line1\n line2" 4)))
+ (should (string= result "line1\nline2"))))
+
+(ert-deftest test-dedent-three-lines-2-spaces ()
+ "Should dedent three lines with 2 spaces."
+ (let ((result (test-dedent " a\n b\n c" 2)))
+ (should (string= result "a\nb\nc"))))
+
+(ert-deftest test-dedent-with-tabs ()
+ "Should dedent lines with tabs."
+ (let ((result (test-dedent "\tline1\n\tline2" 1)))
+ (should (string= result "line1\nline2"))))
+
+(ert-deftest test-dedent-mixed-spaces-tabs ()
+ "Should dedent mixed spaces and tabs."
+ (let ((result (test-dedent " \tline" 3)))
+ (should (string= result "line"))))
+
+;;; Dedent Tests - Partial Dedent
+
+(ert-deftest test-dedent-partial ()
+ "Should dedent only COUNT characters."
+ (let ((result (test-dedent " line" 2)))
+ (should (string= result " line"))))
+
+(ert-deftest test-dedent-less-than-count ()
+ "Should dedent all available spaces when less than COUNT."
+ (let ((result (test-dedent " line" 4)))
+ (should (string= result "line"))))
+
+(ert-deftest test-dedent-no-leading-space ()
+ "Should not affect lines with no leading whitespace."
+ (let ((result (test-dedent "line" 4)))
+ (should (string= result "line"))))
+
+(ert-deftest test-dedent-varying-indentation ()
+ "Should dedent each line independently."
+ (let ((result (test-dedent " line1\n line2\nline3" 2)))
+ (should (string= result " line1\nline2\nline3"))))
+
+;;; Dedent Tests - Boundary Cases
+
+(ert-deftest test-dedent-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-dedent "" 4)))
+ (should (string= result ""))))
+
+(ert-deftest test-dedent-zero-count ()
+ "Should not dedent with count 0."
+ (let ((result (test-dedent " line" 0)))
+ (should (string= result " line"))))
+
+(ert-deftest test-dedent-empty-lines ()
+ "Should handle empty lines."
+ (let ((result (test-dedent " line1\n \n line3" 4)))
+ (should (string= result "line1\n\nline3"))))
+
+(ert-deftest test-dedent-only-whitespace ()
+ "Should dedent whitespace-only lines."
+ (let ((result (test-dedent " " 4)))
+ (should (string= result ""))))
+
+(ert-deftest test-dedent-trailing-newline ()
+ "Should preserve trailing newline."
+ (let ((result (test-dedent " line1\n line2\n" 4)))
+ (should (string= result "line1\nline2\n"))))
+
+(ert-deftest test-dedent-preserves-internal-spaces ()
+ "Should not affect internal whitespace."
+ (let ((result (test-dedent " hello world" 4)))
+ (should (string= result "hello world"))))
+
+;;; Round-trip Tests
+
+(ert-deftest test-indent-dedent-roundtrip ()
+ "Should be able to indent then dedent back to original."
+ (let* ((original "line1\nline2")
+ (indented (test-indent original 4 nil))
+ (dedented (test-dedent indented 4)))
+ (should (string= dedented original))))
+
+(ert-deftest test-dedent-indent-roundtrip ()
+ "Should be able to dedent then indent back to original."
+ (let* ((original " line1\n line2")
+ (dedented (test-dedent original 4))
+ (indented (test-indent dedented 4 nil)))
+ (should (string= indented original))))
+
+;;; Edge Cases
+
+(ert-deftest test-indent-very-long-line ()
+ "Should indent very long line."
+ (let* ((long-line (make-string 1000 ?a))
+ (result (test-indent long-line 4 nil)))
+ (should (string-prefix-p " " result))
+ (should (= (length result) 1004))))
+
+(ert-deftest test-dedent-very-indented ()
+ "Should dedent very indented line."
+ (let* ((many-spaces (make-string 100 ?\s))
+ (text (concat many-spaces "text"))
+ (result (test-dedent text 50)))
+ (should (string-prefix-p (make-string 50 ?\s) result))))
+
+(ert-deftest test-indent-with-existing-tabs ()
+ "Should indent lines that already have tabs."
+ (let ((result (test-indent "\tcode" 4 nil)))
+ (should (string= result " \tcode"))))
+
+(ert-deftest test-dedent-stops-at-non-whitespace ()
+ "Should stop dedenting at first non-whitespace character."
+ (let ((result (test-dedent " a b" 4)))
+ (should (string= result "a b"))))
+
+(provide 'test-custom-text-enclose-indent)
+;;; test-custom-text-enclose-indent.el ends here
diff --git a/tests/test-custom-text-enclose-prepend.el b/tests/test-custom-text-enclose-prepend.el
new file mode 100644
index 00000000..e03375ff
--- /dev/null
+++ b/tests/test-custom-text-enclose-prepend.el
@@ -0,0 +1,207 @@
+;;; test-custom-text-enclose-prepend.el --- Tests for cj/--prepend-to-lines -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--prepend-to-lines function from custom-text-enclose.el
+;;
+;; This function prepends a prefix string to the beginning of each line in text.
+;; It preserves the structure of lines and handles trailing newlines correctly.
+;;
+;; Examples:
+;; Input: "line1\nline2", prefix: "// "
+;; Output: "// line1\n// line2"
+;;
+;; Input: "single", prefix: "> "
+;; Output: "> single"
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--prepend-to-lines) to avoid
+;; mocking region selection. This follows our testing best practice of
+;; separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-text-enclose)
+
+;;; Test Helpers
+
+(defun test-prepend-to-lines (text prefix)
+ "Test cj/--prepend-to-lines on TEXT with PREFIX.
+Returns the transformed string."
+ (cj/--prepend-to-lines text prefix))
+
+;;; Normal Cases - Single Line
+
+(ert-deftest test-prepend-single-line ()
+ "Should prepend to single line."
+ (let ((result (test-prepend-to-lines "hello" "> ")))
+ (should (string= result "> hello"))))
+
+(ert-deftest test-prepend-single-line-comment ()
+ "Should prepend comment marker to single line."
+ (let ((result (test-prepend-to-lines "code here" "// ")))
+ (should (string= result "// code here"))))
+
+(ert-deftest test-prepend-single-line-bullet ()
+ "Should prepend bullet to single line."
+ (let ((result (test-prepend-to-lines "item" "- ")))
+ (should (string= result "- item"))))
+
+;;; Normal Cases - Multiple Lines
+
+(ert-deftest test-prepend-two-lines ()
+ "Should prepend to two lines."
+ (let ((result (test-prepend-to-lines "line1\nline2" "> ")))
+ (should (string= result "> line1\n> line2"))))
+
+(ert-deftest test-prepend-three-lines ()
+ "Should prepend to three lines."
+ (let ((result (test-prepend-to-lines "a\nb\nc" "* ")))
+ (should (string= result "* a\n* b\n* c"))))
+
+(ert-deftest test-prepend-many-lines ()
+ "Should prepend to many lines."
+ (let* ((lines (make-list 10 "line"))
+ (input (mapconcat #'identity lines "\n"))
+ (result (test-prepend-to-lines input "# "))
+ (result-lines (split-string result "\n")))
+ (should (= 10 (length result-lines)))
+ (should (cl-every (lambda (line) (string-prefix-p "# " line)) result-lines))))
+
+;;; Normal Cases - Various Prefixes
+
+(ert-deftest test-prepend-comment-marker ()
+ "Should prepend comment marker."
+ (let ((result (test-prepend-to-lines "line1\nline2" "// ")))
+ (should (string= result "// line1\n// line2"))))
+
+(ert-deftest test-prepend-hash-comment ()
+ "Should prepend hash comment."
+ (let ((result (test-prepend-to-lines "line1\nline2" "# ")))
+ (should (string= result "# line1\n# line2"))))
+
+(ert-deftest test-prepend-multi-char ()
+ "Should prepend multi-character prefix."
+ (let ((result (test-prepend-to-lines "line" "TODO: ")))
+ (should (string= result "TODO: line"))))
+
+(ert-deftest test-prepend-empty-prefix ()
+ "Should handle empty prefix."
+ (let ((result (test-prepend-to-lines "line1\nline2" "")))
+ (should (string= result "line1\nline2"))))
+
+;;; Boundary Cases - Trailing Newlines
+
+(ert-deftest test-prepend-with-trailing-newline ()
+ "Should preserve trailing newline."
+ (let ((result (test-prepend-to-lines "line1\nline2\n" "> ")))
+ (should (string= result "> line1\n> line2\n"))))
+
+(ert-deftest test-prepend-no-trailing-newline ()
+ "Should work without trailing newline."
+ (let ((result (test-prepend-to-lines "line1\nline2" "> ")))
+ (should (string= result "> line1\n> line2"))))
+
+(ert-deftest test-prepend-single-line-with-newline ()
+ "Should preserve trailing newline on single line."
+ (let ((result (test-prepend-to-lines "line\n" "> ")))
+ (should (string= result "> line\n"))))
+
+;;; Boundary Cases - Empty Lines
+
+(ert-deftest test-prepend-empty-line-between ()
+ "Should prepend to empty line between other lines."
+ (let ((result (test-prepend-to-lines "line1\n\nline3" "> ")))
+ (should (string= result "> line1\n> \n> line3"))))
+
+(ert-deftest test-prepend-only-empty-lines ()
+ "Should prepend to only empty lines."
+ (let ((result (test-prepend-to-lines "\n\n" "> ")))
+ (should (string= result "> \n> \n"))))
+
+(ert-deftest test-prepend-empty-first-line ()
+ "Should prepend to empty first line."
+ (let ((result (test-prepend-to-lines "\nline2\nline3" "> ")))
+ (should (string= result "> \n> line2\n> line3"))))
+
+;;; Boundary Cases - Whitespace
+
+(ert-deftest test-prepend-preserves-leading-whitespace ()
+ "Should preserve leading whitespace after prefix."
+ (let ((result (test-prepend-to-lines " line1\n line2" "// ")))
+ (should (string= result "// line1\n// line2"))))
+
+(ert-deftest test-prepend-preserves-trailing-whitespace ()
+ "Should preserve trailing whitespace on line."
+ (let ((result (test-prepend-to-lines "line1 \nline2 " "> ")))
+ (should (string= result "> line1 \n> line2 "))))
+
+(ert-deftest test-prepend-whitespace-only-line ()
+ "Should prepend to whitespace-only line."
+ (let ((result (test-prepend-to-lines "line1\n \nline3" "> ")))
+ (should (string= result "> line1\n> \n> line3"))))
+
+;;; Boundary Cases - Special Cases
+
+(ert-deftest test-prepend-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-prepend-to-lines "" "> ")))
+ (should (string= result "> "))))
+
+(ert-deftest test-prepend-very-long-line ()
+ "Should prepend to very long line."
+ (let* ((long-line (make-string 1000 ?a))
+ (result (test-prepend-to-lines long-line "> ")))
+ (should (string-prefix-p "> " result))
+ (should (= (length result) 1002))))
+
+(ert-deftest test-prepend-with-existing-prefix ()
+ "Should prepend even if line already has the prefix."
+ (let ((result (test-prepend-to-lines "> line" "> ")))
+ (should (string= result "> > line"))))
+
+;;; Edge Cases - Special Characters in Prefix
+
+(ert-deftest test-prepend-newline-prefix ()
+ "Should prepend newline as prefix."
+ (let ((result (test-prepend-to-lines "line1\nline2" "\n")))
+ (should (string= result "\nline1\n\nline2"))))
+
+(ert-deftest test-prepend-tab-prefix ()
+ "Should prepend tab as prefix."
+ (let ((result (test-prepend-to-lines "line1\nline2" "\t")))
+ (should (string= result "\tline1\n\tline2"))))
+
+(ert-deftest test-prepend-quote-prefix ()
+ "Should prepend quote as prefix."
+ (let ((result (test-prepend-to-lines "line1\nline2" "\"")))
+ (should (string= result "\"line1\n\"line2"))))
+
+;;; Edge Cases - Common Use Cases
+
+(ert-deftest test-prepend-markdown-quote ()
+ "Should prepend markdown quote marker."
+ (let ((result (test-prepend-to-lines "quote text\nmore text" "> ")))
+ (should (string= result "> quote text\n> more text"))))
+
+(ert-deftest test-prepend-numbered-list ()
+ "Should prepend numbers (though simpler uses would vary the prefix)."
+ (let ((result (test-prepend-to-lines "item" "1. ")))
+ (should (string= result "1. item"))))
+
+(ert-deftest test-prepend-indentation ()
+ "Should prepend indentation spaces."
+ (let ((result (test-prepend-to-lines "code\nmore" " ")))
+ (should (string= result " code\n more"))))
+
+(provide 'test-custom-text-enclose-prepend)
+;;; test-custom-text-enclose-prepend.el ends here
diff --git a/tests/test-custom-text-enclose-surround.el b/tests/test-custom-text-enclose-surround.el
new file mode 100644
index 00000000..dfed20a7
--- /dev/null
+++ b/tests/test-custom-text-enclose-surround.el
@@ -0,0 +1,200 @@
+;;; test-custom-text-enclose-surround.el --- Tests for cj/--surround -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--surround function from custom-text-enclose.el
+;;
+;; This function surrounds text with a given string.
+;; The surround string is both prepended and appended to the text.
+;;
+;; Examples:
+;; Input: "hello", surround: "\""
+;; Output: "\"hello\""
+;;
+;; Input: "world", surround: "**"
+;; Output: "**world**"
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--surround) to avoid
+;; mocking user input. This follows our testing best practice of
+;; separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-text-enclose)
+
+;;; Test Helpers
+
+(defun test-surround (text surround-string)
+ "Test cj/--surround on TEXT with SURROUND-STRING.
+Returns the transformed string."
+ (cj/--surround text surround-string))
+
+;;; Normal Cases - Common Surround Strings
+
+(ert-deftest test-surround-double-quotes ()
+ "Should surround text with double quotes."
+ (let ((result (test-surround "hello" "\"")))
+ (should (string= result "\"hello\""))))
+
+(ert-deftest test-surround-single-quotes ()
+ "Should surround text with single quotes."
+ (let ((result (test-surround "world" "'")))
+ (should (string= result "'world'"))))
+
+(ert-deftest test-surround-parentheses ()
+ "Should surround text with parentheses."
+ (let ((result (test-surround "text" "(")))
+ (should (string= result "(text("))))
+
+(ert-deftest test-surround-square-brackets ()
+ "Should surround text with square brackets."
+ (let ((result (test-surround "item" "[")))
+ (should (string= result "[item["))))
+
+(ert-deftest test-surround-asterisks ()
+ "Should surround text with asterisks for markdown."
+ (let ((result (test-surround "bold" "*")))
+ (should (string= result "*bold*"))))
+
+(ert-deftest test-surround-double-asterisks ()
+ "Should surround text with double asterisks."
+ (let ((result (test-surround "bold" "**")))
+ (should (string= result "**bold**"))))
+
+;;; Normal Cases - Multi-Character Surround Strings
+
+(ert-deftest test-surround-html-tag ()
+ "Should surround text with HTML-like tags."
+ (let ((result (test-surround "content" "<tag>")))
+ (should (string= result "<tag>content<tag>"))))
+
+(ert-deftest test-surround-backticks ()
+ "Should surround text with backticks for code."
+ (let ((result (test-surround "code" "`")))
+ (should (string= result "`code`"))))
+
+(ert-deftest test-surround-triple-backticks ()
+ "Should surround text with triple backticks."
+ (let ((result (test-surround "code block" "```")))
+ (should (string= result "```code block```"))))
+
+(ert-deftest test-surround-custom-delimiter ()
+ "Should surround text with custom delimiter."
+ (let ((result (test-surround "data" "||")))
+ (should (string= result "||data||"))))
+
+;;; Normal Cases - Various Text Content
+
+(ert-deftest test-surround-single-word ()
+ "Should surround single word."
+ (let ((result (test-surround "word" "\"")))
+ (should (string= result "\"word\""))))
+
+(ert-deftest test-surround-multiple-words ()
+ "Should surround multiple words."
+ (let ((result (test-surround "hello world" "\"")))
+ (should (string= result "\"hello world\""))))
+
+(ert-deftest test-surround-sentence ()
+ "Should surround full sentence."
+ (let ((result (test-surround "This is a sentence." "\"")))
+ (should (string= result "\"This is a sentence.\""))))
+
+(ert-deftest test-surround-with-numbers ()
+ "Should surround text with numbers."
+ (let ((result (test-surround "123" "'")))
+ (should (string= result "'123'"))))
+
+(ert-deftest test-surround-with-special-chars ()
+ "Should surround text with special characters."
+ (let ((result (test-surround "hello@world.com" "\"")))
+ (should (string= result "\"hello@world.com\""))))
+
+;;; Normal Cases - Multiline Text
+
+(ert-deftest test-surround-multiline ()
+ "Should surround multiline text."
+ (let ((result (test-surround "line1\nline2\nline3" "\"")))
+ (should (string= result "\"line1\nline2\nline3\""))))
+
+(ert-deftest test-surround-text-with-newlines ()
+ "Should surround text containing newlines."
+ (let ((result (test-surround "first\nsecond" "**")))
+ (should (string= result "**first\nsecond**"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-surround-empty-string ()
+ "Should surround empty string."
+ (let ((result (test-surround "" "\"")))
+ (should (string= result "\"\""))))
+
+(ert-deftest test-surround-single-character ()
+ "Should surround single character."
+ (let ((result (test-surround "x" "\"")))
+ (should (string= result "\"x\""))))
+
+(ert-deftest test-surround-empty-surround-string ()
+ "Should handle empty surround string."
+ (let ((result (test-surround "hello" "")))
+ (should (string= result "hello"))))
+
+(ert-deftest test-surround-very-long-text ()
+ "Should surround very long text."
+ (let* ((long-text (make-string 1000 ?a))
+ (result (test-surround long-text "\"")))
+ (should (string-prefix-p "\"" result))
+ (should (string-suffix-p "\"" result))
+ (should (= (length result) 1002))))
+
+(ert-deftest test-surround-whitespace-only ()
+ "Should surround whitespace-only text."
+ (let ((result (test-surround " " "\"")))
+ (should (string= result "\" \""))))
+
+(ert-deftest test-surround-tabs ()
+ "Should surround text with tabs."
+ (let ((result (test-surround "\t\ttext\t\t" "\"")))
+ (should (string= result "\"\t\ttext\t\t\""))))
+
+;;; Edge Cases - Already Surrounded
+
+(ert-deftest test-surround-already-quoted ()
+ "Should surround text that is already quoted."
+ (let ((result (test-surround "\"hello\"" "\"")))
+ (should (string= result "\"\"hello\"\""))))
+
+(ert-deftest test-surround-nested ()
+ "Should surround text creating nested delimiters."
+ (let ((result (test-surround "'inner'" "\"")))
+ (should (string= result "\"'inner'\""))))
+
+;;; Edge Cases - Special Surround Strings
+
+(ert-deftest test-surround-space ()
+ "Should surround text with spaces."
+ (let ((result (test-surround "text" " ")))
+ (should (string= result " text "))))
+
+(ert-deftest test-surround-newline ()
+ "Should surround text with newlines."
+ (let ((result (test-surround "text" "\n")))
+ (should (string= result "\ntext\n"))))
+
+(ert-deftest test-surround-mixed-delimiters ()
+ "Should surround with mixed delimiter string."
+ (let ((result (test-surround "content" "<>")))
+ (should (string= result "<>content<>"))))
+
+(provide 'test-custom-text-enclose-surround)
+;;; test-custom-text-enclose-surround.el ends here
diff --git a/tests/test-custom-text-enclose-unwrap.el b/tests/test-custom-text-enclose-unwrap.el
new file mode 100644
index 00000000..a308b644
--- /dev/null
+++ b/tests/test-custom-text-enclose-unwrap.el
@@ -0,0 +1,266 @@
+;;; test-custom-text-enclose-unwrap.el --- Tests for cj/--unwrap -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--unwrap function from custom-text-enclose.el
+;;
+;; This function removes surrounding delimiters from text.
+;; It checks if text starts with opening and ends with closing,
+;; and if so, removes them.
+;;
+;; Examples:
+;; Input: "(text)", opening: "(", closing: ")"
+;; Output: "text"
+;;
+;; Input: "<div>content</div>", opening: "<div>", closing: "</div>"
+;; Output: "content"
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--unwrap) to avoid
+;; mocking user input. This follows our testing best practice of
+;; separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-text-enclose)
+
+;;; Test Helpers
+
+(defun test-unwrap (text opening closing)
+ "Test cj/--unwrap on TEXT with OPENING and CLOSING.
+Returns the transformed string."
+ (cj/--unwrap text opening closing))
+
+;;; Normal Cases - Common Bracket Types
+
+(ert-deftest test-unwrap-parentheses ()
+ "Should unwrap text with parentheses."
+ (let ((result (test-unwrap "(text)" "(" ")")))
+ (should (string= result "text"))))
+
+(ert-deftest test-unwrap-square-brackets ()
+ "Should unwrap text with square brackets."
+ (let ((result (test-unwrap "[item]" "[" "]")))
+ (should (string= result "item"))))
+
+(ert-deftest test-unwrap-curly-braces ()
+ "Should unwrap text with curly braces."
+ (let ((result (test-unwrap "{code}" "{" "}")))
+ (should (string= result "code"))))
+
+(ert-deftest test-unwrap-angle-brackets ()
+ "Should unwrap text with angle brackets."
+ (let ((result (test-unwrap "<tag>" "<" ">")))
+ (should (string= result "tag"))))
+
+;;; Normal Cases - HTML/XML Tags
+
+(ert-deftest test-unwrap-html-div ()
+ "Should unwrap HTML div tags."
+ (let ((result (test-unwrap "<div>content</div>" "<div>" "</div>")))
+ (should (string= result "content"))))
+
+(ert-deftest test-unwrap-html-span ()
+ "Should unwrap HTML span tags."
+ (let ((result (test-unwrap "<span>text</span>" "<span>" "</span>")))
+ (should (string= result "text"))))
+
+(ert-deftest test-unwrap-xml-tag ()
+ "Should unwrap XML tags."
+ (let ((result (test-unwrap "<item>data</item>" "<item>" "</item>")))
+ (should (string= result "data"))))
+
+(ert-deftest test-unwrap-html-with-attributes ()
+ "Should unwrap HTML tag containing attributes."
+ (let ((result (test-unwrap "<a href=\"url\">link</a>" "<a href=\"url\">" "</a>")))
+ (should (string= result "link"))))
+
+;;; Normal Cases - Markdown Syntax
+
+(ert-deftest test-unwrap-markdown-bold ()
+ "Should unwrap markdown bold syntax."
+ (let ((result (test-unwrap "**bold**" "**" "**")))
+ (should (string= result "bold"))))
+
+(ert-deftest test-unwrap-markdown-italic ()
+ "Should unwrap markdown italic syntax."
+ (let ((result (test-unwrap "*italic*" "*" "*")))
+ (should (string= result "italic"))))
+
+(ert-deftest test-unwrap-markdown-code ()
+ "Should unwrap markdown code syntax."
+ (let ((result (test-unwrap "`code`" "`" "`")))
+ (should (string= result "code"))))
+
+(ert-deftest test-unwrap-quotes ()
+ "Should unwrap double quotes."
+ (let ((result (test-unwrap "\"text\"" "\"" "\"")))
+ (should (string= result "text"))))
+
+;;; Normal Cases - Various Content
+
+(ert-deftest test-unwrap-single-word ()
+ "Should unwrap single word."
+ (let ((result (test-unwrap "(word)" "(" ")")))
+ (should (string= result "word"))))
+
+(ert-deftest test-unwrap-multiple-words ()
+ "Should unwrap multiple words."
+ (let ((result (test-unwrap "(hello world)" "(" ")")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-unwrap-sentence ()
+ "Should unwrap full sentence."
+ (let ((result (test-unwrap "(This is a sentence.)" "(" ")")))
+ (should (string= result "This is a sentence."))))
+
+(ert-deftest test-unwrap-with-numbers ()
+ "Should unwrap text with numbers."
+ (let ((result (test-unwrap "[123]" "[" "]")))
+ (should (string= result "123"))))
+
+(ert-deftest test-unwrap-with-special-chars ()
+ "Should unwrap text with special characters."
+ (let ((result (test-unwrap "<hello@world.com>" "<" ">")))
+ (should (string= result "hello@world.com"))))
+
+;;; Normal Cases - Multiline Text
+
+(ert-deftest test-unwrap-multiline ()
+ "Should unwrap multiline text."
+ (let ((result (test-unwrap "<div>line1\nline2\nline3</div>" "<div>" "</div>")))
+ (should (string= result "line1\nline2\nline3"))))
+
+(ert-deftest test-unwrap-text-with-newlines ()
+ "Should unwrap text containing newlines."
+ (let ((result (test-unwrap "(first\nsecond)" "(" ")")))
+ (should (string= result "first\nsecond"))))
+
+;;; Boundary Cases - No Match
+
+(ert-deftest test-unwrap-no-opening ()
+ "Should not unwrap when opening is missing."
+ (let ((result (test-unwrap "text)" "(" ")")))
+ (should (string= result "text)"))))
+
+(ert-deftest test-unwrap-no-closing ()
+ "Should not unwrap when closing is missing."
+ (let ((result (test-unwrap "(text" "(" ")")))
+ (should (string= result "(text"))))
+
+(ert-deftest test-unwrap-neither-delimiter ()
+ "Should not unwrap when neither delimiter is present."
+ (let ((result (test-unwrap "text" "(" ")")))
+ (should (string= result "text"))))
+
+(ert-deftest test-unwrap-wrong-opening ()
+ "Should not unwrap with wrong opening delimiter."
+ (let ((result (test-unwrap "[text)" "(" ")")))
+ (should (string= result "[text)"))))
+
+(ert-deftest test-unwrap-wrong-closing ()
+ "Should not unwrap with wrong closing delimiter."
+ (let ((result (test-unwrap "(text]" "(" ")")))
+ (should (string= result "(text]"))))
+
+;;; Boundary Cases - Empty
+
+(ert-deftest test-unwrap-empty-content ()
+ "Should unwrap to empty string."
+ (let ((result (test-unwrap "()" "(" ")")))
+ (should (string= result ""))))
+
+(ert-deftest test-unwrap-just-delimiters ()
+ "Should unwrap when only delimiters present."
+ (let ((result (test-unwrap "[]" "[" "]")))
+ (should (string= result ""))))
+
+(ert-deftest test-unwrap-empty-string ()
+ "Should return empty string unchanged."
+ (let ((result (test-unwrap "" "(" ")")))
+ (should (string= result ""))))
+
+(ert-deftest test-unwrap-too-short ()
+ "Should not unwrap when text is shorter than delimiters."
+ (let ((result (test-unwrap "x" "<div>" "</div>")))
+ (should (string= result "x"))))
+
+;;; Boundary Cases - Nested/Multiple
+
+(ert-deftest test-unwrap-nested-same ()
+ "Should unwrap only outer layer of nested delimiters."
+ (let ((result (test-unwrap "((text))" "(" ")")))
+ (should (string= result "(text)"))))
+
+(ert-deftest test-unwrap-nested-different ()
+ "Should unwrap outer layer with different inner delimiters."
+ (let ((result (test-unwrap "([text])" "(" ")")))
+ (should (string= result "[text]"))))
+
+(ert-deftest test-unwrap-multiple-in-content ()
+ "Should not unwrap when delimiters appear in content."
+ (let ((result (test-unwrap "(a)b(c)" "(" ")")))
+ (should (string= result "a)b(c"))))
+
+;;; Edge Cases - Special Delimiters
+
+(ert-deftest test-unwrap-asymmetric-length ()
+ "Should unwrap with different length delimiters."
+ (let ((result (test-unwrap "<<text>>>" "<<" ">>>")))
+ (should (string= result "text"))))
+
+(ert-deftest test-unwrap-multi-char-delimiters ()
+ "Should unwrap with multi-character delimiters."
+ (let ((result (test-unwrap "BEGINdataEND" "BEGIN" "END")))
+ (should (string= result "data"))))
+
+(ert-deftest test-unwrap-space-delimiters ()
+ "Should unwrap with space delimiters."
+ (let ((result (test-unwrap " text " " " " ")))
+ (should (string= result "text"))))
+
+(ert-deftest test-unwrap-newline-delimiters ()
+ "Should unwrap with newline delimiters."
+ (let ((result (test-unwrap "\ntext\n" "\n" "\n")))
+ (should (string= result "text"))))
+
+;;; Edge Cases - Same Opening and Closing
+
+(ert-deftest test-unwrap-same-delimiters ()
+ "Should unwrap when opening and closing are the same."
+ (let ((result (test-unwrap "*text*" "*" "*")))
+ (should (string= result "text"))))
+
+(ert-deftest test-unwrap-same-multi-char ()
+ "Should unwrap same multi-char delimiters."
+ (let ((result (test-unwrap "***text***" "***" "***")))
+ (should (string= result "text"))))
+
+;;; Edge Cases - Empty Delimiters
+
+(ert-deftest test-unwrap-empty-opening ()
+ "Should handle empty opening delimiter."
+ (let ((result (test-unwrap "text)" "" ")")))
+ (should (string= result "text"))))
+
+(ert-deftest test-unwrap-empty-closing ()
+ "Should handle empty closing delimiter."
+ (let ((result (test-unwrap "(text" "(" "")))
+ (should (string= result "text"))))
+
+(ert-deftest test-unwrap-both-delimiters-empty ()
+ "Should return text unchanged when both delimiters empty."
+ (let ((result (test-unwrap "text" "" "")))
+ (should (string= result "text"))))
+
+(provide 'test-custom-text-enclose-unwrap)
+;;; test-custom-text-enclose-unwrap.el ends here
diff --git a/tests/test-custom-text-enclose-wrap.el b/tests/test-custom-text-enclose-wrap.el
new file mode 100644
index 00000000..f68a0668
--- /dev/null
+++ b/tests/test-custom-text-enclose-wrap.el
@@ -0,0 +1,240 @@
+;;; test-custom-text-enclose-wrap.el --- Tests for cj/--wrap -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--wrap function from custom-text-enclose.el
+;;
+;; This function wraps text with different opening and closing strings.
+;; Unlike surround which uses the same string on both sides, wrap allows
+;; asymmetric delimiters.
+;;
+;; Examples:
+;; Input: "content", opening: "<div>", closing: "</div>"
+;; Output: "<div>content</div>"
+;;
+;; Input: "text", opening: "(", closing: ")"
+;; Output: "(text)"
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--wrap) to avoid
+;; mocking user input. This follows our testing best practice of
+;; separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-text-enclose)
+
+;;; Test Helpers
+
+(defun test-wrap (text opening closing)
+ "Test cj/--wrap on TEXT with OPENING and CLOSING.
+Returns the transformed string."
+ (cj/--wrap text opening closing))
+
+;;; Normal Cases - Common Bracket Types
+
+(ert-deftest test-wrap-parentheses ()
+ "Should wrap text with parentheses."
+ (let ((result (test-wrap "text" "(" ")")))
+ (should (string= result "(text)"))))
+
+(ert-deftest test-wrap-square-brackets ()
+ "Should wrap text with square brackets."
+ (let ((result (test-wrap "item" "[" "]")))
+ (should (string= result "[item]"))))
+
+(ert-deftest test-wrap-curly-braces ()
+ "Should wrap text with curly braces."
+ (let ((result (test-wrap "code" "{" "}")))
+ (should (string= result "{code}"))))
+
+(ert-deftest test-wrap-angle-brackets ()
+ "Should wrap text with angle brackets."
+ (let ((result (test-wrap "tag" "<" ">")))
+ (should (string= result "<tag>"))))
+
+;;; Normal Cases - HTML/XML Tags
+
+(ert-deftest test-wrap-html-div ()
+ "Should wrap text with HTML div tags."
+ (let ((result (test-wrap "content" "<div>" "</div>")))
+ (should (string= result "<div>content</div>"))))
+
+(ert-deftest test-wrap-html-span ()
+ "Should wrap text with HTML span tags."
+ (let ((result (test-wrap "text" "<span>" "</span>")))
+ (should (string= result "<span>text</span>"))))
+
+(ert-deftest test-wrap-xml-tag ()
+ "Should wrap text with XML tags."
+ (let ((result (test-wrap "data" "<item>" "</item>")))
+ (should (string= result "<item>data</item>"))))
+
+(ert-deftest test-wrap-html-with-attributes ()
+ "Should wrap text with HTML tag containing attributes."
+ (let ((result (test-wrap "link" "<a href=\"url\">" "</a>")))
+ (should (string= result "<a href=\"url\">link</a>"))))
+
+;;; Normal Cases - Markdown Syntax
+
+(ert-deftest test-wrap-markdown-bold ()
+ "Should wrap text with markdown bold syntax."
+ (let ((result (test-wrap "bold" "**" "**")))
+ (should (string= result "**bold**"))))
+
+(ert-deftest test-wrap-markdown-italic ()
+ "Should wrap text with markdown italic syntax."
+ (let ((result (test-wrap "italic" "*" "*")))
+ (should (string= result "*italic*"))))
+
+(ert-deftest test-wrap-markdown-code ()
+ "Should wrap text with markdown code syntax."
+ (let ((result (test-wrap "code" "`" "`")))
+ (should (string= result "`code`"))))
+
+(ert-deftest test-wrap-markdown-link ()
+ "Should wrap text with markdown link syntax."
+ (let ((result (test-wrap "text" "[" "](url)")))
+ (should (string= result "[text](url)"))))
+
+;;; Normal Cases - Various Content
+
+(ert-deftest test-wrap-single-word ()
+ "Should wrap single word."
+ (let ((result (test-wrap "word" "(" ")")))
+ (should (string= result "(word)"))))
+
+(ert-deftest test-wrap-multiple-words ()
+ "Should wrap multiple words."
+ (let ((result (test-wrap "hello world" "(" ")")))
+ (should (string= result "(hello world)"))))
+
+(ert-deftest test-wrap-sentence ()
+ "Should wrap full sentence."
+ (let ((result (test-wrap "This is a sentence." "(" ")")))
+ (should (string= result "(This is a sentence.)"))))
+
+(ert-deftest test-wrap-with-numbers ()
+ "Should wrap text with numbers."
+ (let ((result (test-wrap "123" "[" "]")))
+ (should (string= result "[123]"))))
+
+(ert-deftest test-wrap-with-special-chars ()
+ "Should wrap text with special characters."
+ (let ((result (test-wrap "hello@world.com" "<" ">")))
+ (should (string= result "<hello@world.com>"))))
+
+;;; Normal Cases - Multiline Text
+
+(ert-deftest test-wrap-multiline ()
+ "Should wrap multiline text."
+ (let ((result (test-wrap "line1\nline2\nline3" "<div>" "</div>")))
+ (should (string= result "<div>line1\nline2\nline3</div>"))))
+
+(ert-deftest test-wrap-text-with-newlines ()
+ "Should wrap text containing newlines."
+ (let ((result (test-wrap "first\nsecond" "(" ")")))
+ (should (string= result "(first\nsecond)"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-wrap-empty-string ()
+ "Should wrap empty string."
+ (let ((result (test-wrap "" "(" ")")))
+ (should (string= result "()"))))
+
+(ert-deftest test-wrap-single-character ()
+ "Should wrap single character."
+ (let ((result (test-wrap "x" "[" "]")))
+ (should (string= result "[x]"))))
+
+(ert-deftest test-wrap-empty-opening ()
+ "Should handle empty opening delimiter."
+ (let ((result (test-wrap "text" "" ")")))
+ (should (string= result "text)"))))
+
+(ert-deftest test-wrap-empty-closing ()
+ "Should handle empty closing delimiter."
+ (let ((result (test-wrap "text" "(" "")))
+ (should (string= result "(text"))))
+
+(ert-deftest test-wrap-both-empty ()
+ "Should handle both delimiters empty."
+ (let ((result (test-wrap "text" "" "")))
+ (should (string= result "text"))))
+
+(ert-deftest test-wrap-very-long-text ()
+ "Should wrap very long text."
+ (let* ((long-text (make-string 1000 ?a))
+ (result (test-wrap long-text "(" ")")))
+ (should (string-prefix-p "(" result))
+ (should (string-suffix-p ")" result))
+ (should (= (length result) 1002))))
+
+(ert-deftest test-wrap-whitespace-only ()
+ "Should wrap whitespace-only text."
+ (let ((result (test-wrap " " "(" ")")))
+ (should (string= result "( )"))))
+
+(ert-deftest test-wrap-tabs ()
+ "Should wrap text with tabs."
+ (let ((result (test-wrap "\t\ttext\t\t" "[" "]")))
+ (should (string= result "[\t\ttext\t\t]"))))
+
+;;; Edge Cases - Already Wrapped
+
+(ert-deftest test-wrap-already-wrapped ()
+ "Should wrap text that is already wrapped."
+ (let ((result (test-wrap "(hello)" "[" "]")))
+ (should (string= result "[(hello)]"))))
+
+(ert-deftest test-wrap-nested ()
+ "Should wrap text creating nested delimiters."
+ (let ((result (test-wrap "[inner]" "(" ")")))
+ (should (string= result "([inner])"))))
+
+;;; Edge Cases - Special Delimiters
+
+(ert-deftest test-wrap-asymmetric-length ()
+ "Should wrap with different length delimiters."
+ (let ((result (test-wrap "text" "<<" ">>>")))
+ (should (string= result "<<text>>>"))))
+
+(ert-deftest test-wrap-multi-char-delimiters ()
+ "Should wrap with multi-character delimiters."
+ (let ((result (test-wrap "data" "BEGIN" "END")))
+ (should (string= result "BEGINdataEND"))))
+
+(ert-deftest test-wrap-space-delimiters ()
+ "Should wrap with space delimiters."
+ (let ((result (test-wrap "text" " " " ")))
+ (should (string= result " text "))))
+
+(ert-deftest test-wrap-newline-delimiters ()
+ "Should wrap with newline delimiters."
+ (let ((result (test-wrap "text" "\n" "\n")))
+ (should (string= result "\ntext\n"))))
+
+(ert-deftest test-wrap-quote-delimiters ()
+ "Should wrap with quote delimiters."
+ (let ((result (test-wrap "text" "\"" "\"")))
+ (should (string= result "\"text\""))))
+
+;;; Edge Cases - Same Opening and Closing
+
+(ert-deftest test-wrap-same-delimiters ()
+ "Should work like surround when delimiters are the same."
+ (let ((result (test-wrap "text" "*" "*")))
+ (should (string= result "*text*"))))
+
+(provide 'test-custom-text-enclose-wrap)
+;;; test-custom-text-enclose-wrap.el ends here
diff --git a/tests/test-custom-whitespace-collapse.el b/tests/test-custom-whitespace-collapse.el
new file mode 100644
index 00000000..40face95
--- /dev/null
+++ b/tests/test-custom-whitespace-collapse.el
@@ -0,0 +1,150 @@
+;;; test-custom-whitespace-collapse.el --- Tests for cj/--collapse-whitespace -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--collapse-whitespace function from custom-whitespace.el
+;;
+;; This function collapses whitespace in text by:
+;; - Converting all tabs to spaces
+;; - Removing leading and trailing whitespace
+;; - Collapsing multiple consecutive spaces to single space
+;; - Preserving newlines and text structure
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--collapse-whitespace)
+;; to avoid mocking region selection. This follows our testing best practice
+;; of separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-whitespace)
+
+;;; Test Helpers
+
+(defun test-collapse-whitespace (input-text)
+ "Test cj/--collapse-whitespace on INPUT-TEXT.
+Returns the buffer string after operation."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--collapse-whitespace (point-min) (point-max))
+ (buffer-string)))
+
+;;; Normal Cases
+
+(ert-deftest test-collapse-whitespace-multiple-spaces ()
+ "Should collapse multiple spaces to single space."
+ (let ((result (test-collapse-whitespace "hello world")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-collapse-whitespace-multiple-tabs ()
+ "Should convert tabs to spaces and collapse."
+ (let ((result (test-collapse-whitespace "hello\t\t\tworld")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-collapse-whitespace-mixed-tabs-spaces ()
+ "Should handle mixed tabs and spaces."
+ (let ((result (test-collapse-whitespace "hello \t \t world")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-collapse-whitespace-leading-trailing ()
+ "Should remove leading and trailing whitespace."
+ (let ((result (test-collapse-whitespace " hello world ")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-collapse-whitespace-tabs-leading-trailing ()
+ "Should remove leading and trailing tabs."
+ (let ((result (test-collapse-whitespace "\t\thello world\t\t")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-collapse-whitespace-multiple-words ()
+ "Should collapse spaces between multiple words."
+ (let ((result (test-collapse-whitespace "one two three four")))
+ (should (string= result "one two three four"))))
+
+(ert-deftest test-collapse-whitespace-preserve-newlines ()
+ "Should preserve newlines while collapsing spaces."
+ (let ((result (test-collapse-whitespace "hello world\nfoo bar")))
+ (should (string= result "hello world\nfoo bar"))))
+
+(ert-deftest test-collapse-whitespace-multiple-lines ()
+ "Should handle multiple lines with various whitespace."
+ (let ((result (test-collapse-whitespace " hello world \n\t\tfoo bar\t\t")))
+ (should (string= result "hello world\nfoo bar"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-collapse-whitespace-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-collapse-whitespace "")))
+ (should (string= result ""))))
+
+(ert-deftest test-collapse-whitespace-single-char ()
+ "Should handle single character with surrounding spaces."
+ (let ((result (test-collapse-whitespace " x ")))
+ (should (string= result "x"))))
+
+(ert-deftest test-collapse-whitespace-only-whitespace ()
+ "Should handle text with only whitespace (becomes empty)."
+ (let ((result (test-collapse-whitespace " \t \t ")))
+ (should (string= result ""))))
+
+(ert-deftest test-collapse-whitespace-no-extra-whitespace ()
+ "Should handle text with no extra whitespace (no-op)."
+ (let ((result (test-collapse-whitespace "hello world")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-collapse-whitespace-single-space ()
+ "Should handle text with already-collapsed spaces (no-op)."
+ (let ((result (test-collapse-whitespace "one two three")))
+ (should (string= result "one two three"))))
+
+(ert-deftest test-collapse-whitespace-very-long-line ()
+ "Should handle very long lines with many spaces."
+ (let ((result (test-collapse-whitespace "word word word word word")))
+ (should (string= result "word word word word word"))))
+
+(ert-deftest test-collapse-whitespace-multiple-newlines ()
+ "Should preserve multiple newlines while removing spaces."
+ (let ((result (test-collapse-whitespace "hello world\n\n\nfoo bar")))
+ (should (string= result "hello world\n\n\nfoo bar"))))
+
+(ert-deftest test-collapse-whitespace-spaces-around-newlines ()
+ "Should remove spaces before/after newlines."
+ (let ((result (test-collapse-whitespace "hello \n world")))
+ (should (string= result "hello\nworld"))))
+
+(ert-deftest test-collapse-whitespace-empty-lines ()
+ "Should handle empty lines (lines become empty after whitespace removal)."
+ (let ((result (test-collapse-whitespace "line1\n \nline2")))
+ (should (string= result "line1\n\nline2"))))
+
+;;; Error Cases
+
+(ert-deftest test-collapse-whitespace-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "hello world")
+ (cj/--collapse-whitespace (point-max) (point-min)))
+ :type 'error))
+
+(ert-deftest test-collapse-whitespace-empty-region ()
+ "Should handle empty region (start == end) without error."
+ (with-temp-buffer
+ (insert "hello world")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (cj/--collapse-whitespace pos pos)
+ ;; Should complete without error and not change buffer
+ (should (string= (buffer-string) "hello world")))))
+
+(provide 'test-custom-whitespace-collapse)
+;;; test-custom-whitespace-collapse.el ends here
diff --git a/tests/test-custom-whitespace-delete-all.el b/tests/test-custom-whitespace-delete-all.el
new file mode 100644
index 00000000..00abb1d4
--- /dev/null
+++ b/tests/test-custom-whitespace-delete-all.el
@@ -0,0 +1,150 @@
+;;; test-custom-whitespace-delete-all.el --- Tests for cj/--delete-all-whitespace -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--delete-all-whitespace function from custom-whitespace.el
+;;
+;; This function removes ALL whitespace characters from the region:
+;; spaces, tabs, newlines, and carriage returns. Useful for creating
+;; compact identifiers or removing all formatting.
+;;
+;; Uses the regexp [ \t\n\r]+ to match all whitespace.
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--delete-all-whitespace)
+;; to avoid mocking region selection. This follows our testing best practice
+;; of separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-whitespace)
+
+;;; Test Helpers
+
+(defun test-delete-all-whitespace (input-text)
+ "Test cj/--delete-all-whitespace on INPUT-TEXT.
+Returns the buffer string after operation."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--delete-all-whitespace (point-min) (point-max))
+ (buffer-string)))
+
+;;; Normal Cases
+
+(ert-deftest test-delete-all-whitespace-single-space ()
+ "Should remove single space."
+ (let ((result (test-delete-all-whitespace "hello world")))
+ (should (string= result "helloworld"))))
+
+(ert-deftest test-delete-all-whitespace-multiple-spaces ()
+ "Should remove multiple spaces."
+ (let ((result (test-delete-all-whitespace "hello world")))
+ (should (string= result "helloworld"))))
+
+(ert-deftest test-delete-all-whitespace-tabs ()
+ "Should remove tabs."
+ (let ((result (test-delete-all-whitespace "hello\tworld")))
+ (should (string= result "helloworld"))))
+
+(ert-deftest test-delete-all-whitespace-newlines ()
+ "Should remove newlines (joining lines)."
+ (let ((result (test-delete-all-whitespace "hello\nworld")))
+ (should (string= result "helloworld"))))
+
+(ert-deftest test-delete-all-whitespace-mixed ()
+ "Should remove all types of whitespace."
+ (let ((result (test-delete-all-whitespace "hello \t\n world")))
+ (should (string= result "helloworld"))))
+
+(ert-deftest test-delete-all-whitespace-multiple-words ()
+ "Should remove whitespace from multiple words."
+ (let ((result (test-delete-all-whitespace "one two three four")))
+ (should (string= result "onetwothreefour"))))
+
+(ert-deftest test-delete-all-whitespace-multiline ()
+ "Should remove all whitespace across multiple lines."
+ (let ((result (test-delete-all-whitespace "line1\nline2\nline3")))
+ (should (string= result "line1line2line3"))))
+
+(ert-deftest test-delete-all-whitespace-leading-trailing ()
+ "Should remove leading and trailing whitespace."
+ (let ((result (test-delete-all-whitespace " hello world ")))
+ (should (string= result "helloworld"))))
+
+(ert-deftest test-delete-all-whitespace-carriage-returns ()
+ "Should handle carriage returns."
+ (let ((result (test-delete-all-whitespace "hello\r\nworld")))
+ (should (string= result "helloworld"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-delete-all-whitespace-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-delete-all-whitespace "")))
+ (should (string= result ""))))
+
+(ert-deftest test-delete-all-whitespace-no-whitespace ()
+ "Should handle text with no whitespace (no-op)."
+ (let ((result (test-delete-all-whitespace "helloworld")))
+ (should (string= result "helloworld"))))
+
+(ert-deftest test-delete-all-whitespace-only-whitespace ()
+ "Should delete all content when only whitespace exists."
+ (let ((result (test-delete-all-whitespace " \t \n ")))
+ (should (string= result ""))))
+
+(ert-deftest test-delete-all-whitespace-single-char ()
+ "Should handle single character with surrounding whitespace."
+ (let ((result (test-delete-all-whitespace " x ")))
+ (should (string= result "x"))))
+
+(ert-deftest test-delete-all-whitespace-very-long-text ()
+ "Should handle very long text."
+ (let ((result (test-delete-all-whitespace "word word word word word word word word")))
+ (should (string= result "wordwordwordwordwordwordwordword"))))
+
+(ert-deftest test-delete-all-whitespace-single-whitespace ()
+ "Should delete single whitespace character."
+ (let ((result (test-delete-all-whitespace " ")))
+ (should (string= result ""))))
+
+(ert-deftest test-delete-all-whitespace-consecutive-newlines ()
+ "Should remove all consecutive newlines."
+ (let ((result (test-delete-all-whitespace "hello\n\n\nworld")))
+ (should (string= result "helloworld"))))
+
+(ert-deftest test-delete-all-whitespace-complex-structure ()
+ "Should handle complex whitespace patterns."
+ (let ((result (test-delete-all-whitespace " hello\n\t world \n foo\t\tbar ")))
+ (should (string= result "helloworldfoobar"))))
+
+;;; Error Cases
+
+(ert-deftest test-delete-all-whitespace-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "hello world")
+ (cj/--delete-all-whitespace (point-max) (point-min)))
+ :type 'error))
+
+(ert-deftest test-delete-all-whitespace-empty-region ()
+ "Should handle empty region (start == end) without error."
+ (with-temp-buffer
+ (insert "hello world")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (cj/--delete-all-whitespace pos pos)
+ ;; Should complete without error and not change buffer
+ (should (string= (buffer-string) "hello world")))))
+
+(provide 'test-custom-whitespace-delete-all)
+;;; test-custom-whitespace-delete-all.el ends here
diff --git a/tests/test-custom-whitespace-delete-blank-lines.el b/tests/test-custom-whitespace-delete-blank-lines.el
new file mode 100644
index 00000000..2d250521
--- /dev/null
+++ b/tests/test-custom-whitespace-delete-blank-lines.el
@@ -0,0 +1,146 @@
+;;; test-custom-whitespace-delete-blank-lines.el --- Tests for cj/--delete-blank-lines -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--delete-blank-lines function from custom-whitespace.el
+;;
+;; This function deletes blank lines from text, where blank lines are defined
+;; as lines containing only whitespace (spaces, tabs) or nothing at all.
+;; Uses the regexp ^[[:space:]]*$ to match blank lines.
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--delete-blank-lines)
+;; to avoid mocking user prompts. This follows our testing best practice
+;; of separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-whitespace)
+
+;;; Test Helpers
+
+(defun test-delete-blank-lines (input-text)
+ "Test cj/--delete-blank-lines on INPUT-TEXT.
+Returns the buffer string after operation."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--delete-blank-lines (point-min) (point-max))
+ (buffer-string)))
+
+;;; Normal Cases
+
+(ert-deftest test-delete-blank-lines-single-blank ()
+ "Should delete single blank line between text."
+ (let ((result (test-delete-blank-lines "line1\n\nline2")))
+ (should (string= result "line1\nline2"))))
+
+(ert-deftest test-delete-blank-lines-multiple-consecutive ()
+ "Should delete multiple consecutive blank lines."
+ (let ((result (test-delete-blank-lines "line1\n\n\n\nline2")))
+ (should (string= result "line1\nline2"))))
+
+(ert-deftest test-delete-blank-lines-spaces-only ()
+ "Should delete lines with spaces only."
+ (let ((result (test-delete-blank-lines "line1\n \nline2")))
+ (should (string= result "line1\nline2"))))
+
+(ert-deftest test-delete-blank-lines-tabs-only ()
+ "Should delete lines with tabs only."
+ (let ((result (test-delete-blank-lines "line1\n\t\t\nline2")))
+ (should (string= result "line1\nline2"))))
+
+(ert-deftest test-delete-blank-lines-mixed-whitespace ()
+ "Should delete lines with mixed whitespace."
+ (let ((result (test-delete-blank-lines "line1\n \t \t \nline2")))
+ (should (string= result "line1\nline2"))))
+
+(ert-deftest test-delete-blank-lines-no-blank-lines ()
+ "Should handle text with no blank lines (no-op)."
+ (let ((result (test-delete-blank-lines "line1\nline2\nline3")))
+ (should (string= result "line1\nline2\nline3"))))
+
+(ert-deftest test-delete-blank-lines-at-start ()
+ "Should delete blank lines at start of region."
+ (let ((result (test-delete-blank-lines "\n\nline1\nline2")))
+ (should (string= result "line1\nline2"))))
+
+(ert-deftest test-delete-blank-lines-at-end ()
+ "Should delete blank lines at end of region."
+ (let ((result (test-delete-blank-lines "line1\nline2\n\n")))
+ (should (string= result "line1\nline2\n"))))
+
+(ert-deftest test-delete-blank-lines-scattered ()
+ "Should delete blank lines scattered throughout text."
+ (let ((result (test-delete-blank-lines "line1\n\nline2\n \nline3\n\t\nline4")))
+ (should (string= result "line1\nline2\nline3\nline4"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-delete-blank-lines-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-delete-blank-lines "")))
+ (should (string= result ""))))
+
+(ert-deftest test-delete-blank-lines-only-blank-lines ()
+ "Should delete all lines if only blank lines exist."
+ (let ((result (test-delete-blank-lines "\n\n\n")))
+ (should (string= result ""))))
+
+(ert-deftest test-delete-blank-lines-only-whitespace ()
+ "Should delete lines containing only whitespace."
+ (let ((result (test-delete-blank-lines " \n\t\t\n \t ")))
+ (should (string= result ""))))
+
+(ert-deftest test-delete-blank-lines-single-line-content ()
+ "Should handle single line with content (no-op)."
+ (let ((result (test-delete-blank-lines "hello world")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-delete-blank-lines-single-blank-line ()
+ "Should delete single blank line."
+ (let ((result (test-delete-blank-lines "\n")))
+ (should (string= result ""))))
+
+(ert-deftest test-delete-blank-lines-very-long-region ()
+ "Should handle very long region with many blank lines."
+ (let* ((lines (make-list 100 "content"))
+ (input (mapconcat #'identity lines "\n\n"))
+ (expected (mapconcat #'identity lines "\n"))
+ (result (test-delete-blank-lines input)))
+ (should (string= result expected))))
+
+(ert-deftest test-delete-blank-lines-preserve-content-lines ()
+ "Should preserve lines with any non-whitespace content."
+ (let ((result (test-delete-blank-lines "x\n\ny\n \nz")))
+ (should (string= result "x\ny\nz"))))
+
+;;; Error Cases
+
+(ert-deftest test-delete-blank-lines-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "line1\n\nline2")
+ (cj/--delete-blank-lines (point-max) (point-min)))
+ :type 'error))
+
+(ert-deftest test-delete-blank-lines-empty-region ()
+ "Should handle empty region (start == end) without error."
+ (with-temp-buffer
+ (insert "line1\n\nline2")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (cj/--delete-blank-lines pos pos)
+ ;; Should complete without error
+ (should (string-match-p "line1" (buffer-string))))))
+
+(provide 'test-custom-whitespace-delete-blank-lines)
+;;; test-custom-whitespace-delete-blank-lines.el ends here
diff --git a/tests/test-custom-whitespace-ensure-single-blank.el b/tests/test-custom-whitespace-ensure-single-blank.el
new file mode 100644
index 00000000..7cd03e79
--- /dev/null
+++ b/tests/test-custom-whitespace-ensure-single-blank.el
@@ -0,0 +1,146 @@
+;;; test-custom-whitespace-ensure-single-blank.el --- Tests for cj/--ensure-single-blank-line -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--ensure-single-blank-line function from custom-whitespace.el
+;;
+;; This function collapses multiple consecutive blank lines to exactly one blank line.
+;; Different from delete-blank-lines which removes ALL blank lines, this function
+;; preserves blank lines but ensures no more than one blank line appears consecutively.
+;;
+;; A blank line is defined as a line containing only whitespace (spaces, tabs) or nothing.
+;; Uses the regexp (^[[:space:]]*$\n){2,} to match 2+ consecutive blank lines.
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--ensure-single-blank-line)
+;; to avoid mocking user prompts. This follows our testing best practice
+;; of separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-whitespace)
+
+;;; Test Helpers
+
+(defun test-ensure-single-blank-line (input-text)
+ "Test cj/--ensure-single-blank-line on INPUT-TEXT.
+Returns the buffer string after operation."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--ensure-single-blank-line (point-min) (point-max))
+ (buffer-string)))
+
+;;; Normal Cases
+
+(ert-deftest test-ensure-single-blank-two-blanks ()
+ "Should collapse two blank lines to one."
+ (let ((result (test-ensure-single-blank-line "line1\n\n\nline2")))
+ (should (string= result "line1\n\nline2"))))
+
+(ert-deftest test-ensure-single-blank-three-blanks ()
+ "Should collapse three blank lines to one."
+ (let ((result (test-ensure-single-blank-line "line1\n\n\n\nline2")))
+ (should (string= result "line1\n\nline2"))))
+
+(ert-deftest test-ensure-single-blank-many-blanks ()
+ "Should collapse many blank lines to one."
+ (let ((result (test-ensure-single-blank-line "line1\n\n\n\n\n\n\nline2")))
+ (should (string= result "line1\n\nline2"))))
+
+(ert-deftest test-ensure-single-blank-preserve-single ()
+ "Should preserve single blank lines (no-op)."
+ (let ((result (test-ensure-single-blank-line "line1\n\nline2")))
+ (should (string= result "line1\n\nline2"))))
+
+(ert-deftest test-ensure-single-blank-multiple-groups ()
+ "Should handle multiple groups of consecutive blanks."
+ (let ((result (test-ensure-single-blank-line "line1\n\n\nline2\n\n\n\nline3")))
+ (should (string= result "line1\n\nline2\n\nline3"))))
+
+(ert-deftest test-ensure-single-blank-blanks-with-spaces ()
+ "Should handle blank lines with spaces only."
+ (let ((result (test-ensure-single-blank-line "line1\n \n \nline2")))
+ (should (string= result "line1\n\nline2"))))
+
+(ert-deftest test-ensure-single-blank-blanks-with-tabs ()
+ "Should handle blank lines with tabs only."
+ (let ((result (test-ensure-single-blank-line "line1\n\t\t\n\t\t\nline2")))
+ (should (string= result "line1\n\nline2"))))
+
+(ert-deftest test-ensure-single-blank-mixed-whitespace ()
+ "Should handle blank lines with mixed whitespace."
+ (let ((result (test-ensure-single-blank-line "line1\n \t \n \t \nline2")))
+ (should (string= result "line1\n\nline2"))))
+
+(ert-deftest test-ensure-single-blank-no-blanks ()
+ "Should handle text with no blank lines (no-op)."
+ (let ((result (test-ensure-single-blank-line "line1\nline2\nline3")))
+ (should (string= result "line1\nline2\nline3"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-ensure-single-blank-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-ensure-single-blank-line "")))
+ (should (string= result ""))))
+
+(ert-deftest test-ensure-single-blank-only-blanks ()
+ "Should collapse many blank lines to one blank line."
+ (let ((result (test-ensure-single-blank-line "\n\n\n\n")))
+ (should (string= result "\n\n"))))
+
+(ert-deftest test-ensure-single-blank-at-start ()
+ "Should collapse multiple blank lines at start to one."
+ (let ((result (test-ensure-single-blank-line "\n\n\nline1")))
+ (should (string= result "\n\nline1"))))
+
+(ert-deftest test-ensure-single-blank-at-end ()
+ "Should collapse multiple blank lines at end to one."
+ (let ((result (test-ensure-single-blank-line "line1\n\n\n")))
+ (should (string= result "line1\n\n"))))
+
+(ert-deftest test-ensure-single-blank-single-line ()
+ "Should handle single line (no-op)."
+ (let ((result (test-ensure-single-blank-line "line1")))
+ (should (string= result "line1"))))
+
+(ert-deftest test-ensure-single-blank-complex-structure ()
+ "Should handle complex mix of content and blanks."
+ (let ((result (test-ensure-single-blank-line "line1\n\n\nline2\nline3\n\n\n\nline4")))
+ (should (string= result "line1\n\nline2\nline3\n\nline4"))))
+
+(ert-deftest test-ensure-single-blank-preserves-content ()
+ "Should not modify lines with content."
+ (let ((result (test-ensure-single-blank-line " line1 \n\n\n line2 ")))
+ (should (string= result " line1 \n\n line2 "))))
+
+;;; Error Cases
+
+(ert-deftest test-ensure-single-blank-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "line1\n\n\nline2")
+ (cj/--ensure-single-blank-line (point-max) (point-min)))
+ :type 'error))
+
+(ert-deftest test-ensure-single-blank-empty-region ()
+ "Should handle empty region (start == end) without error."
+ (with-temp-buffer
+ (insert "line1\n\n\nline2")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (cj/--ensure-single-blank-line pos pos)
+ ;; Should complete without error
+ (should (string-match-p "line1" (buffer-string))))))
+
+(provide 'test-custom-whitespace-ensure-single-blank)
+;;; test-custom-whitespace-ensure-single-blank.el ends here
diff --git a/tests/test-custom-whitespace-hyphenate.el b/tests/test-custom-whitespace-hyphenate.el
new file mode 100644
index 00000000..03462fab
--- /dev/null
+++ b/tests/test-custom-whitespace-hyphenate.el
@@ -0,0 +1,140 @@
+;;; test-custom-whitespace-hyphenate.el --- Tests for cj/--hyphenate-whitespace -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--hyphenate-whitespace function from custom-whitespace.el
+;;
+;; This function replaces all runs of whitespace (spaces, tabs, newlines,
+;; carriage returns) with single hyphens. Useful for converting text with
+;; whitespace into hyphenated identifiers or URLs.
+;;
+;; Uses the regexp [ \t\n\r]+ to match whitespace runs.
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--hyphenate-whitespace)
+;; to avoid mocking region selection. This follows our testing best practice
+;; of separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-whitespace)
+
+;;; Test Helpers
+
+(defun test-hyphenate-whitespace (input-text)
+ "Test cj/--hyphenate-whitespace on INPUT-TEXT.
+Returns the buffer string after operation."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--hyphenate-whitespace (point-min) (point-max))
+ (buffer-string)))
+
+;;; Normal Cases
+
+(ert-deftest test-hyphenate-whitespace-single-space ()
+ "Should replace single space with hyphen."
+ (let ((result (test-hyphenate-whitespace "hello world")))
+ (should (string= result "hello-world"))))
+
+(ert-deftest test-hyphenate-whitespace-multiple-spaces ()
+ "Should replace multiple spaces with single hyphen."
+ (let ((result (test-hyphenate-whitespace "hello world")))
+ (should (string= result "hello-world"))))
+
+(ert-deftest test-hyphenate-whitespace-tabs ()
+ "Should replace tabs with hyphen."
+ (let ((result (test-hyphenate-whitespace "hello\tworld")))
+ (should (string= result "hello-world"))))
+
+(ert-deftest test-hyphenate-whitespace-mixed-tabs-spaces ()
+ "Should replace mixed tabs and spaces with single hyphen."
+ (let ((result (test-hyphenate-whitespace "hello \t world")))
+ (should (string= result "hello-world"))))
+
+(ert-deftest test-hyphenate-whitespace-newlines ()
+ "Should replace newlines with hyphen (joining lines)."
+ (let ((result (test-hyphenate-whitespace "hello\nworld")))
+ (should (string= result "hello-world"))))
+
+(ert-deftest test-hyphenate-whitespace-multiple-newlines ()
+ "Should replace multiple newlines with single hyphen."
+ (let ((result (test-hyphenate-whitespace "hello\n\n\nworld")))
+ (should (string= result "hello-world"))))
+
+(ert-deftest test-hyphenate-whitespace-multiple-words ()
+ "Should hyphenate multiple words with various whitespace."
+ (let ((result (test-hyphenate-whitespace "one two three\tfour\nfive")))
+ (should (string= result "one-two-three-four-five"))))
+
+(ert-deftest test-hyphenate-whitespace-carriage-returns ()
+ "Should handle carriage returns."
+ (let ((result (test-hyphenate-whitespace "hello\r\nworld")))
+ (should (string= result "hello-world"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-hyphenate-whitespace-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-hyphenate-whitespace "")))
+ (should (string= result ""))))
+
+(ert-deftest test-hyphenate-whitespace-no-whitespace ()
+ "Should handle text with no whitespace (no-op)."
+ (let ((result (test-hyphenate-whitespace "helloworld")))
+ (should (string= result "helloworld"))))
+
+(ert-deftest test-hyphenate-whitespace-only-whitespace ()
+ "Should convert text with only whitespace to single hyphen."
+ (let ((result (test-hyphenate-whitespace " \t \n ")))
+ (should (string= result "-"))))
+
+(ert-deftest test-hyphenate-whitespace-single-char ()
+ "Should handle single character with surrounding spaces."
+ (let ((result (test-hyphenate-whitespace " x ")))
+ (should (string= result "-x-"))))
+
+(ert-deftest test-hyphenate-whitespace-very-long-text ()
+ "Should handle very long text with many spaces."
+ (let ((result (test-hyphenate-whitespace "word word word word word word word word")))
+ (should (string= result "word-word-word-word-word-word-word-word"))))
+
+(ert-deftest test-hyphenate-whitespace-leading-whitespace ()
+ "Should replace leading whitespace with hyphen."
+ (let ((result (test-hyphenate-whitespace " hello world")))
+ (should (string= result "-hello-world"))))
+
+(ert-deftest test-hyphenate-whitespace-trailing-whitespace ()
+ "Should replace trailing whitespace with hyphen."
+ (let ((result (test-hyphenate-whitespace "hello world ")))
+ (should (string= result "hello-world-"))))
+
+;;; Error Cases
+
+(ert-deftest test-hyphenate-whitespace-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "hello world")
+ (cj/--hyphenate-whitespace (point-max) (point-min)))
+ :type 'error))
+
+(ert-deftest test-hyphenate-whitespace-empty-region ()
+ "Should handle empty region (start == end) without error."
+ (with-temp-buffer
+ (insert "hello world")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (cj/--hyphenate-whitespace pos pos)
+ ;; Should complete without error and not change buffer
+ (should (string= (buffer-string) "hello world")))))
+
+(provide 'test-custom-whitespace-hyphenate)
+;;; test-custom-whitespace-hyphenate.el ends here
diff --git a/tests/test-custom-whitespace-remove-leading-trailing.el b/tests/test-custom-whitespace-remove-leading-trailing.el
new file mode 100644
index 00000000..5a846e7f
--- /dev/null
+++ b/tests/test-custom-whitespace-remove-leading-trailing.el
@@ -0,0 +1,157 @@
+;;; test-custom-whitespace-remove-leading-trailing.el --- Tests for cj/--remove-leading-trailing-whitespace -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--remove-leading-trailing-whitespace function from custom-whitespace.el
+;;
+;; This function removes leading and trailing whitespace (spaces and tabs) from text.
+;; - Removes leading whitespace: ^[ \t]+
+;; - Removes trailing whitespace: [ \t]+$
+;; - Preserves interior whitespace
+;; - Operates on any region defined by START and END
+;;
+;; We test the NON-INTERACTIVE implementation (cj/--remove-leading-trailing-whitespace)
+;; to avoid mocking region selection and prefix arguments. This follows our testing
+;; best practice of separating business logic from UI interaction.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'custom-whitespace)
+
+;;; Test Helpers
+
+(defun test-remove-leading-trailing (input-text)
+ "Test cj/--remove-leading-trailing-whitespace on INPUT-TEXT.
+Returns the buffer string after operation."
+ (with-temp-buffer
+ (insert input-text)
+ (cj/--remove-leading-trailing-whitespace (point-min) (point-max))
+ (buffer-string)))
+
+;;; Normal Cases
+
+(ert-deftest test-remove-leading-trailing-leading-spaces ()
+ "Should remove leading spaces from single line."
+ (let ((result (test-remove-leading-trailing " hello world")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-remove-leading-trailing-trailing-spaces ()
+ "Should remove trailing spaces from single line."
+ (let ((result (test-remove-leading-trailing "hello world ")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-remove-leading-trailing-both-spaces ()
+ "Should remove both leading and trailing spaces."
+ (let ((result (test-remove-leading-trailing " hello world ")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-remove-leading-trailing-leading-tabs ()
+ "Should remove leading tabs from single line."
+ (let ((result (test-remove-leading-trailing "\t\thello world")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-remove-leading-trailing-trailing-tabs ()
+ "Should remove trailing tabs from single line."
+ (let ((result (test-remove-leading-trailing "hello world\t\t")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-remove-leading-trailing-mixed-tabs-spaces ()
+ "Should remove mixed tabs and spaces."
+ (let ((result (test-remove-leading-trailing " \t hello world \t ")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-remove-leading-trailing-preserve-interior ()
+ "Should preserve interior whitespace."
+ (let ((result (test-remove-leading-trailing " hello world \t")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-remove-leading-trailing-multiple-lines ()
+ "Should handle multiple lines with leading/trailing whitespace."
+ (let ((result (test-remove-leading-trailing " line1 \n\t\tline2\t\n line3 ")))
+ (should (string= result "line1\nline2\nline3"))))
+
+(ert-deftest test-remove-leading-trailing-multiline-preserve-interior ()
+ "Should preserve interior whitespace on multiple lines."
+ (let ((result (test-remove-leading-trailing " hello world \n foo bar ")))
+ (should (string= result "hello world\nfoo bar"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-remove-leading-trailing-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-remove-leading-trailing "")))
+ (should (string= result ""))))
+
+(ert-deftest test-remove-leading-trailing-single-char ()
+ "Should handle single character with surrounding spaces."
+ (let ((result (test-remove-leading-trailing " x ")))
+ (should (string= result "x"))))
+
+(ert-deftest test-remove-leading-trailing-only-whitespace ()
+ "Should handle lines with only whitespace."
+ (let ((result (test-remove-leading-trailing " \t ")))
+ (should (string= result ""))))
+
+(ert-deftest test-remove-leading-trailing-no-whitespace ()
+ "Should handle text with no leading/trailing whitespace (no-op)."
+ (let ((result (test-remove-leading-trailing "hello world")))
+ (should (string= result "hello world"))))
+
+(ert-deftest test-remove-leading-trailing-very-long-line ()
+ "Should handle very long lines with whitespace."
+ (let* ((long-text (make-string 500 ?x))
+ (input (concat " " long-text " "))
+ (result (test-remove-leading-trailing input)))
+ (should (string= result long-text))))
+
+(ert-deftest test-remove-leading-trailing-whitespace-between-lines ()
+ "Should handle lines that become empty after removal."
+ (let ((result (test-remove-leading-trailing "line1\n \nline2")))
+ (should (string= result "line1\n\nline2"))))
+
+(ert-deftest test-remove-leading-trailing-newlines-only ()
+ "Should preserve newlines while removing spaces."
+ (let ((result (test-remove-leading-trailing "\n\n\n")))
+ (should (string= result "\n\n\n"))))
+
+(ert-deftest test-remove-leading-trailing-partial-region ()
+ "Should work on partial buffer region."
+ (with-temp-buffer
+ (insert " hello \n world \n test ")
+ ;; Only operate on middle line
+ (let ((start (+ (point-min) 10)) ; Start of second line
+ (end (+ (point-min) 19))) ; End of second line
+ (cj/--remove-leading-trailing-whitespace start end)
+ (should (string= (buffer-string) " hello \nworld\n test ")))))
+
+;;; Error Cases
+
+(ert-deftest test-remove-leading-trailing-start-greater-than-end ()
+ "Should error when start > end."
+ (should-error
+ (with-temp-buffer
+ (insert "hello world")
+ (cj/--remove-leading-trailing-whitespace (point-max) (point-min)))
+ :type 'error))
+
+(ert-deftest test-remove-leading-trailing-empty-region ()
+ "Should handle empty region (start == end) without error."
+ (with-temp-buffer
+ (insert "hello world")
+ (let ((pos (/ (+ (point-min) (point-max)) 2)))
+ (cj/--remove-leading-trailing-whitespace pos pos)
+ ;; Should complete without error and not change buffer
+ (should (string= (buffer-string) "hello world")))))
+
+(provide 'test-custom-whitespace-remove-leading-trailing)
+;;; test-custom-whitespace-remove-leading-trailing.el ends here
diff --git a/tests/test-fixup-whitespace.el.disabled b/tests/test-fixup-whitespace.el.disabled
deleted file mode 100644
index 0126801a..00000000
--- a/tests/test-fixup-whitespace.el.disabled
+++ /dev/null
@@ -1,159 +0,0 @@
-;;; test-fixup-whitespace.el --- -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; Test cj/fixup-whitespace-line-or-region in custom-functions.el
-
-;; The function under test should:
-;; - ensure there is exactly one space between words
-;; - remove tab characters
-;; - remove leading and trailing whitespace
-;; - operate on a line, or a region, if selected
-
-;;; Code:
-
-
-(require 'ert)
-(add-to-list 'load-path (concat user-emacs-directory "modules"))
-(require 'custom-functions)
-
-(ert-deftest test-cj/fixup-whitespace-positive-first-line-only ()
- "Test a positive case with two lines.
-Both lines have whitespace at the beginning and the end. This tests that when
-this function is called on the first line, only that line is affected."
- (let ((testdata " Hello, world! \n Foo bar ")
- (expected "Hello, world!\n Foo bar ")
- (actual))
- (with-temp-buffer
- (insert testdata)
- (goto-char (point-min))
- (cj/fixup-whitespace-line-or-region)
- (setq actual (buffer-string))
- (should (string= actual expected)))))
-
-(ert-deftest test-cj/fixup-whitespace-positive-first-line-only-tabs ()
- "Test a positive case with two lines.
-Both lines have extraneous whitespace at the beginning and the end, includuing
-tabs. This tests that when this function is called on the first line, only that
-line is affected."
- (let ((testdata " Hello,\t world! \n Foo\tbar ")
- (expected "Hello, world!\n Foo\tbar ")
- (actual))
- (with-temp-buffer
- (insert testdata)
- (goto-char (point-min))
- (cj/fixup-whitespace-line-or-region)
- (setq actual (buffer-string))
- (should (string= actual expected)))))
-
-(ert-deftest test-cj/fixup-whitespace-positive-first-line-only-tabs2 ()
- "Test a positive case with two lines.
-Both lines have extraneous whitespace at the beginning and the end, includuing
-tabs. This tests that when this function is called on the first line, only that
-line is affected."
- (let ((testdata "\t Hello,\tworld! \n Foo\t bar\t ")
- (expected "Hello, world!\n Foo\t bar\t ")
- (actual))
- (with-temp-buffer
- (insert testdata)
- (goto-char (point-min))
- (cj/fixup-whitespace-line-or-region)
- (setq actual (buffer-string))
- (should (string= actual expected)))))
-
-(ert-deftest test-cj/fixup-whitespace-negative-first-line-only ()
- "Test a negative case with two lines.
-Only the second line has whitespace at the beginning and the end. This tests
-that when this function is called on the first line, neither line changes."
- (let ((testdata "Hello, world!\n Foo bar ")
- (expected "Hello, world!\n Foo bar ")
- (actual))
- (with-temp-buffer
- (insert testdata)
- (goto-char (point-min))
- (cj/fixup-whitespace-line-or-region)
- (setq actual (buffer-string))
- (should (string= actual expected)))))
-
-(ert-deftest test-cj/fixup-whitespace-positive-second-line-only ()
- "Test a positive case with two lines.
-Both lines have whitespace at the beginning and the end. This tests that when
-function is called on the second line, only that line is affected."
- (let ((testdata " Hello, world! \n Foo bar ")
- (expected " Hello, world! \nFoo bar")
- (actual))
- (with-temp-buffer
- (insert testdata)
- (goto-char (point-min))
- (forward-line)
- (cj/fixup-whitespace-line-or-region)
- (setq actual (buffer-string))
- (should (string= actual expected)))))
-
-(ert-deftest test-cj/fixup-whitespace-negative-second-line-only ()
- "Test a negative case with two lines.
-Only the first line has whitespace at the beginning and the end. This tests
-that when this function is called on the first line, neither line changes."
- (let ((testdata " Hello, world! \nFoo bar")
- (expected " Hello, world! \nFoo bar")
- (actual))
- (with-temp-buffer
- (insert testdata)
- (goto-char (point-min))
- (forward-line)
- (cj/fixup-whitespace-line-or-region)
- (setq actual (buffer-string))
- (should (string= actual expected)))))
-
-(ert-deftest test-cj/fixup-whitespace-positive-region ()
- "Test a positive case with a region.
-Two lines have whitespace at the beginning, the middle, and the end. This tests
-that when this function is called with a region, all whitespace is cleaned up as
-expected."
- (let ((testdata " Hello, world! \n Foo bar ")
- (expected "Hello, world!\nFoo bar")
- (actual))
- (with-temp-buffer
- (insert testdata)
- (goto-char (point-min))
- (set-mark (point))
- (goto-char (point-max))
- (cj/fixup-whitespace-line-or-region t)
- (setq actual (buffer-string))
- (should (string= actual expected)))))
-
-(ert-deftest test-cj/fixup-whitespace-positive-region-tabs ()
- "Test a positive case with a region and tabs.
-Two lines have extraneous whitespace at the beginning, the middle, and the end.
-This tests that when this function is called with a region, all whitespace is
-cleaned up as expected."
- (let ((testdata " \t \t Hello, world! \n Foo\t bar ")
- (expected "Hello, world!\nFoo bar")
- (actual))
- (with-temp-buffer
- (insert testdata)
- (goto-char (point-min))
- (set-mark (point))
- (goto-char (point-max))
- (cj/fixup-whitespace-line-or-region t)
- (setq actual (buffer-string))
- (should (string= actual expected)))))
-
-(ert-deftest test-cj/fixup-whitespace-negative-region ()
- "Test a negative case with a region.
-Two lines are inserted, neither of which have extraneous whitespace. This tests
-that when this function is called with a region, there's no unwanted
-side-effects and nothing changes."
- (let ((testdata "Hello, world!\nFoo bar")
- (expected "Hello, world!\nFoo bar")
- (actual))
- (with-temp-buffer
- (insert testdata)
- (goto-char (point-min))
- (set-mark (point))
- (goto-char (point-max))
- (cj/fixup-whitespace-line-or-region t)
- (setq actual (buffer-string))
- (should (string= actual expected)))))
-
-(provide 'test-fixup-whitespace)
-;;; test-fixup-whitespace.el ends here.
diff --git a/tests/test-flyspell-config-functions.el.disabled b/tests/test-flyspell-config-functions.el.disabled
deleted file mode 100644
index d12ac167..00000000
--- a/tests/test-flyspell-config-functions.el.disabled
+++ /dev/null
@@ -1,149 +0,0 @@
-;;; test-flyspell-config-functions.el --- -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; Evaluate the buffer, then run (ert-all-tests).
-
-;;; Code:
-
-(add-to-list 'load-path (concat user-emacs-directory "modules"))
-(require 'flyspell-and-abbrev)
-
-;; --------------------------- Flyspell Overlay Tests --------------------------
-
-(ert-deftest cj/flyspell-overlay-test-positive ()
- "Simplest positive test for \='cj/find-previous-flyspell-overlay\='.
-With one misspelling, cj/find-previous-flyspell-overlay should return the
-character position at the beginning of the misspelled word."
- (with-temp-buffer
- (let ((misspelled "mispeled")
- (overlay-pos))
- ;; insert some text
- (insert (format "some text for testing. %s" misspelled))
-
- ;; trigger flyspell and wait for it to complete
- (flyspell-buffer)
- (sit-for 1)
-
- ;; call the function with position at end of the buffer
- (setq overlay-pos (cj/find-previous-flyspell-overlay (point-max)))
-
- ;; test flyspell-auto-correct-previous-pos is at char position of 'mispeled'.
- (should (eq (- (point-max) (length misspelled)) overlay-pos)))))
-
-(ert-deftest cj/flyspell-overlay-test-negative ()
- "Simplest negative test for \='cj/find-previous-flyspell-overlay\='.
-With no misspelled words, cj/find-previous-flyspell-overlay should return nil."
- (with-temp-buffer
- (insert "This is a correctly spelled sentence.")
- (flyspell-buffer)
- ;; No overlay should exist, so test the result is nil.
- (should-not (cj/find-previous-flyspell-overlay (point-max)))))
-
-(ert-deftest cj/flyspell-overlay-test-positive-multiple ()
- "Positive test for \='cj/find-previous-flyspell-overlay\='.
-With several misspellings above and below, cj/find-previous-flyspell-overlay
-should return the character position at the beginning of the previous misspelled
-word."
- (with-temp-buffer
- (let ((misspelled0 "incorect")
- (misspelled1 "wrongg")
- (misspelled2 "erroor")
- (misspelled3 "mistken")
- (actual-pos)
- (expected-pos)
- (between-pos))
-
- ;; insert some text with misspellings
- (insert (format "flyspell should catch this: %s" misspelled0))
- (insert (format "flyspell should catch this: %s" misspelled1))
-
- ;; calculate the overlay's expected position based on our current position
- (setq expected-pos (- (point) (length misspelled1)))
-
- ;; calculate a position in between misspellings
- (setq between-pos (+ expected-pos (length misspelled1) 5))
-
- ;; insert the rest of the misspellings
- (insert (format "flyspell should catch this: %s" misspelled2))
- (insert (format "flyspell should catch this: %s" misspelled3))
-
- ;; trigger Flyspell and wait for it to identify all misspellings.
- (flyspell-buffer)
- (sit-for 1)
-
- ;; call the function with position in between misspellings
- (setq actual-pos (cj/find-previous-flyspell-overlay between-pos))
- (should (eq expected-pos actual-pos)))))
-
-
-(ert-deftest cj/flyspell-goto-previous-misspelling-positive ()
- "Positive test for \='cj/flyspell-goto-previous-misspelling\='.
-With a simple misspelling above, cj/flyspell-goto-previous-misspelling
-should land on the next misspelled word."
- (with-temp-buffer
- (let ((misspelled-word "incorect")
- (actual-word))
-
- ;; insert some text with misspellings
- (insert (format "flyspell should catch this: %s" misspelled-word))
-
- ;; trigger Flyspell and wait for it to identify all misspellings.
- (flyspell-buffer)
- (sit-for 1)
-
- ;; call the function with position in between misspellings
- (setq actual-word (cj/flyspell-goto-previous-misspelling (point-max)))
- (should (string= misspelled-word actual-word)))))
-
-(ert-deftest cj/flyspell-goto-previous-misspelling-negative ()
- "Negative test for \='cj/flyspell-goto-previous-misspelling\='.
-With no misspellings, cj/flyspell-goto-previous-misspelling return nil."
- (with-temp-buffer
- (let ((expected nil)
- (result))
-
- ;; insert some text with misspellings
- (insert (format "None of these words are misspelled."))
-
- ;; trigger Flyspell and wait for it to identify all misspellings.
- (flyspell-buffer)
- (sit-for 1)
-
- ;; call the function with position in between misspellings
- (setq result (cj/flyspell-goto-previous-misspelling (point-max)))
- (message "result is %s" result)
- (should (eq result expected)))))
-
-(ert-deftest cj/flyspell-goto-previous-misspelling-positive-multiple ()
- "Positive test for \='cj/flyspell-goto-previous-misspelling\='.
-With several misspellings above and below, cj/flyspell-goto-previous-misspelling
-should return the misspelled word just previous to the position of the cursor."
- (with-temp-buffer
- (let ((misspelled0 "incorect")
- (misspelled1 "wrongg")
- (misspelled2 "erroor")
- (misspelled3 "mistken")
- (result)
- (between-pos))
-
- ;; insert some text with misspellings
- (insert (format "flyspell should catch this: %s\n" misspelled0))
- (insert (format "flyspell should catch this: %s\n" misspelled1))
-
- ;; calculate a position in between misspellings
- (setq between-pos (+ (point) (length misspelled1) 5))
-
- ;; insert the rest of the misspellings
- (insert (format "flyspell should catch this: %s\n" misspelled2))
- (insert (format "flyspell should catch this: %s\n" misspelled3))
-
- ;; trigger Flyspell and wait for it to identify all misspellings.
- (flyspell-buffer)
- (sit-for 1)
-
- ;; call the function with position in between misspellings
- (setq result (cj/flyspell-goto-previous-misspelling between-pos))
- (should (string= result misspelled1)))))
-
-(provide 'test-flyspell-config-functions)
-;;; test-flyspell-config-functions.el ends here.
diff --git a/tests/test-format-region.el.disabled b/tests/test-format-region.el.disabled
deleted file mode 100644
index 25d2e52e..00000000
--- a/tests/test-format-region.el.disabled
+++ /dev/null
@@ -1,110 +0,0 @@
-;;; test-format-region.el --- -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; Some basic tests for the custom function cj/format-region-or-buffer in
-;; custom-functions.el
-
-;;; Code:
-
-(add-to-list 'load-path (concat user-emacs-directory "modules"))
-(require 'custom-functions)
-
-
-;; ----------------------------------- Tests -----------------------------------
-
-(defvar test-format-rob-text-data
- '((" spaces in front\nspaces behind " .
- "spaces in front\nspaces behind")
- ("\t tabs and spaces in front\ntabs and spaces behind\t " .
- "tabs and spaces in front\ntabs and spaces behind")))
-
-(defvar test-format-rob-elisp-data
- '(("(defun existential ()\n(if (eq (+ 3 4) 7)\n(order)\n(chaos)))" .
- "(defun existential ()\n (if (eq (+ 3 4) 7)\n (order)\n (chaos)))")))
-
-
-(ert-deftest test-format-rob-positive-text-region ()
- "Test cj/format-region-or-buffer on a selected region.
-This tests "
- (dolist (data-pair test-format-rob-text-data)
- (let* ((testdata (car data-pair))
- (expected (cdr data-pair))
- (actual))
- (with-temp-buffer
- (insert testdata)
- (goto-char (point-min))
- (set-mark (point))
- (goto-char (point-max))
- (cj/format-region-or-buffer)
- (setq actual (buffer-string))
- (should (string= actual expected))))))
-
-(ert-deftest test-format-rob-positive-text-buffer ()
- "Test cj/format-region-or-buffer on the entire buffer.
-This is the same as testing the region without setting a region in the temp
-buffer."
- (dolist (data-pair test-format-rob-text-data)
- (let* ((testdata (car data-pair))
- (expected (cdr data-pair))
- (actual))
- (with-temp-buffer
- (insert testdata)
- (cj/format-region-or-buffer)
- (setq actual (buffer-string))
- (should (string= actual expected))))))
-
-(ert-deftest test-format-rob-positive-region-text-multiple-paragraphs ()
- "Test cj/format-region-or-buffer on the entire buffer."
- (dolist (data-pair test-format-rob-text-data)
- (let ((testdata (car data-pair))
- (expected1 (cdr data-pair))
- (expected2 (car data-pair))
- (actual1)
- (actual2))
- (with-temp-buffer
- ;; insert data twice with newline char in between
- (insert testdata)
- (insert"\n")
- (insert testdata)
-
- ;; select the first set of data
- (goto-char (point-min))
- (set-mark (point))
- (forward-line 2)
-
- ;; run format and return to top
- (cj/format-region-or-buffer)
- (message "buffer is:\n'%s'" (buffer-string))
-
- ;; assert the first set is formatted
- (goto-char (point-min))
- (setq actual1 (buffer-substring (point-min) (line-end-position 2)))
- (should (string= actual1 expected1))
-
- ;; assert the second set is unformatted
- (goto-char (point-min))
- (setq actual2 (buffer-substring (line-beginning-position 3) (point-max)))
- (should (string= actual2 expected2))))))
-
-(ert-deftest test-format-rob-positive-elisp-region ()
- "Test cj/format-region-or-buffer on a selected region.
-This tests that emacs-lisp specific formatting is applied."
- (ws-butler-mode nil)
- (dolist (data-pair test-format-rob-elisp-data)
- (let* ((testdata (car data-pair))
- (expected (cdr data-pair))
- (actual))
- (with-temp-buffer
- (emacs-lisp-mode)
- (insert testdata)
- (goto-char (point-min))
- (set-mark (point))
- (goto-char (point-max))
- (message "buffer before:\n'%s'" (buffer-string))
- (cj/format-region-or-buffer)
- (message "buffer after:\n'%s'" (buffer-string))
- (setq actual (buffer-string))
- (should (string= actual expected))))))
-
-(provide 'test-format-region)
-;;; test-format-region.el ends here.
diff --git a/tests/test-jumper.el b/tests/test-jumper.el
new file mode 100644
index 00000000..fa65d3f4
--- /dev/null
+++ b/tests/test-jumper.el
@@ -0,0 +1,352 @@
+;;; test-jumper.el --- Tests for jumper.el -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for jumper.el - location navigation using registers.
+;;
+;; Testing approach:
+;; - Tests focus on internal `jumper--do-*` functions (pure business logic)
+;; - Interactive wrappers are thin UI layers and tested minimally
+;; - Each test is isolated with setup/teardown to reset global state
+;; - Tests verify return values, not user messages
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Load the module
+(require 'jumper)
+
+;;; Test Utilities
+
+(defvar test-jumper--original-registers nil
+ "Backup of jumper registers before test.")
+
+(defvar test-jumper--original-index nil
+ "Backup of jumper index before test.")
+
+(defun test-jumper-setup ()
+ "Reset jumper state before each test."
+ ;; Backup current state
+ (setq test-jumper--original-registers jumper--registers)
+ (setq test-jumper--original-index jumper--next-index)
+ ;; Reset to clean state
+ (setq jumper--registers (make-vector jumper-max-locations nil))
+ (setq jumper--next-index 0))
+
+(defun test-jumper-teardown ()
+ "Restore jumper state after each test."
+ (setq jumper--registers test-jumper--original-registers)
+ (setq jumper--next-index test-jumper--original-index))
+
+;;; Normal Cases - Store Location
+
+(ert-deftest test-jumper-store-first-location ()
+ "Should store first location and return register character."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "test content")
+ (goto-char (point-min))
+ (let ((result (jumper--do-store-location)))
+ (should (= result ?0))
+ (should (= jumper--next-index 1))))
+ (test-jumper-teardown))
+
+(ert-deftest test-jumper-store-multiple-locations ()
+ "Should store multiple locations in sequence."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "line 1\nline 2\nline 3")
+ (goto-char (point-min))
+ (should (= (jumper--do-store-location) ?0))
+ (forward-line 1)
+ (should (= (jumper--do-store-location) ?1))
+ (forward-line 1)
+ (should (= (jumper--do-store-location) ?2))
+ (should (= jumper--next-index 3)))
+ (test-jumper-teardown))
+
+(ert-deftest test-jumper-store-duplicate-location ()
+ "Should detect and reject duplicate locations."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "test content")
+ (goto-char (point-min))
+ (should (= (jumper--do-store-location) ?0))
+ (should (eq (jumper--do-store-location) 'already-exists))
+ (should (= jumper--next-index 1)))
+ (test-jumper-teardown))
+
+;;; Normal Cases - Jump to Location
+
+(ert-deftest test-jumper-jump-to-stored-location ()
+ "Should jump to a previously stored location."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "line 1\nline 2\nline 3")
+ (goto-char (point-min))
+ (jumper--do-store-location)
+ (goto-char (point-max))
+ (let ((result (jumper--do-jump-to-location 0)))
+ (should (eq result 'jumped))
+ (should (= (point) (point-min)))))
+ (test-jumper-teardown))
+
+(ert-deftest test-jumper-jump-toggle-with-single-location ()
+ "Should toggle between current and stored location."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "line 1\nline 2\nline 3")
+ (goto-char (point-min))
+ (jumper--do-store-location)
+ ;; Move away
+ (goto-char (point-max))
+ ;; Toggle should jump back
+ (let ((result (jumper--do-jump-to-location nil)))
+ (should (eq result 'jumped))
+ (should (= (point) (point-min)))))
+ (test-jumper-teardown))
+
+(ert-deftest test-jumper-jump-already-at-location ()
+ "Should detect when already at the only stored location."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "line 1\nline 2")
+ (goto-char (point-min))
+ (jumper--do-store-location)
+ ;; Try to toggle while at the location
+ (let ((result (jumper--do-jump-to-location nil)))
+ (should (eq result 'already-there))))
+ (test-jumper-teardown))
+
+;;; Normal Cases - Remove Location
+
+(ert-deftest test-jumper-remove-location ()
+ "Should remove a stored location."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "test content")
+ (goto-char (point-min))
+ (jumper--do-store-location)
+ (let ((result (jumper--do-remove-location 0)))
+ (should (eq result t))
+ (should (= jumper--next-index 0))))
+ (test-jumper-teardown))
+
+(ert-deftest test-jumper-remove-reorders-registers ()
+ "Should reorder registers after removal from middle."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "line 1\nline 2\nline 3")
+ (goto-char (point-min))
+ (jumper--do-store-location) ; Register 0
+ (forward-line 1)
+ (jumper--do-store-location) ; Register 1
+ (forward-line 1)
+ (jumper--do-store-location) ; Register 2
+ ;; Remove middle (index 1)
+ (jumper--do-remove-location 1)
+ (should (= jumper--next-index 2))
+ ;; What was at index 2 should now be at index 1
+ (should (= (aref jumper--registers 1) ?2)))
+ (test-jumper-teardown))
+
+;;; Boundary Cases - Store Location
+
+(ert-deftest test-jumper-store-at-capacity ()
+ "Should successfully store location at maximum capacity."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "test content")
+ (goto-char (point-min))
+ ;; Fill to capacity
+ (dotimes (i jumper-max-locations)
+ (forward-char 1)
+ (should (= (jumper--do-store-location) (+ ?0 i))))
+ (should (= jumper--next-index jumper-max-locations)))
+ (test-jumper-teardown))
+
+(ert-deftest test-jumper-store-when-full ()
+ "Should return 'no-space when all registers are full."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "01234567890123456789")
+ (goto-char (point-min))
+ ;; Fill to capacity
+ (dotimes (i jumper-max-locations)
+ (forward-char 1)
+ (jumper--do-store-location))
+ ;; Try to store one more
+ (forward-char 1)
+ (should (eq (jumper--do-store-location) 'no-space))
+ (should (= jumper--next-index jumper-max-locations)))
+ (test-jumper-teardown))
+
+(ert-deftest test-jumper-store-in-different-buffers ()
+ "Should store locations across different buffers."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "buffer 1")
+ (goto-char (point-min))
+ (should (= (jumper--do-store-location) ?0))
+ (with-temp-buffer
+ (insert "buffer 2")
+ (goto-char (point-min))
+ (should (= (jumper--do-store-location) ?1))
+ (should (= jumper--next-index 2))))
+ (test-jumper-teardown))
+
+;;; Boundary Cases - Jump to Location
+
+(ert-deftest test-jumper-jump-with-no-locations ()
+ "Should return 'no-locations when nothing is stored."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "test")
+ (let ((result (jumper--do-jump-to-location 0)))
+ (should (eq result 'no-locations))))
+ (test-jumper-teardown))
+
+(ert-deftest test-jumper-jump-to-first-location ()
+ "Should jump to location at index 0."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "line 1\nline 2")
+ (goto-char (point-min))
+ (jumper--do-store-location)
+ (forward-line 1)
+ (jumper--do-store-location)
+ (goto-char (point-max))
+ (jumper--do-jump-to-location 0)
+ (should (= (point) (point-min))))
+ (test-jumper-teardown))
+
+(ert-deftest test-jumper-jump-to-last-location ()
+ "Should jump to last location (register 'z)."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "line 1\nline 2\nline 3")
+ (goto-char (point-min))
+ (jumper--do-store-location)
+ (let ((line2-pos (line-beginning-position 2)))
+ (goto-char line2-pos)
+ ;; Jump to location 0 (this stores current location in 'z)
+ (jumper--do-jump-to-location 0)
+ (should (= (point) (point-min)))
+ ;; Jump to last location should go back to line 2
+ (let ((result (jumper--do-jump-to-location -1)))
+ (should (eq result 'jumped))
+ (should (= (point) line2-pos)))))
+ (test-jumper-teardown))
+
+(ert-deftest test-jumper-jump-to-max-index ()
+ "Should jump to location at maximum index."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "0123456789012345678")
+ (goto-char (point-min))
+ ;; Store at all positions
+ (dotimes (i jumper-max-locations)
+ (forward-char 1)
+ (jumper--do-store-location))
+ (goto-char (point-min))
+ ;; Jump to last one (index 9, which is at position 10)
+ (jumper--do-jump-to-location (1- jumper-max-locations))
+ (should (= (point) (1+ jumper-max-locations))))
+ (test-jumper-teardown))
+
+;;; Boundary Cases - Remove Location
+
+(ert-deftest test-jumper-remove-first-location ()
+ "Should remove location at index 0."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "line 1\nline 2")
+ (goto-char (point-min))
+ (jumper--do-store-location)
+ (forward-line 1)
+ (jumper--do-store-location)
+ (jumper--do-remove-location 0)
+ (should (= jumper--next-index 1))
+ ;; What was at index 1 should now be at index 0
+ (should (= (aref jumper--registers 0) ?1)))
+ (test-jumper-teardown))
+
+(ert-deftest test-jumper-remove-last-location ()
+ "Should remove location at last index."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "line 1\nline 2\nline 3")
+ (goto-char (point-min))
+ (jumper--do-store-location)
+ (forward-line 1)
+ (jumper--do-store-location)
+ (forward-line 1)
+ (jumper--do-store-location)
+ (jumper--do-remove-location 2)
+ (should (= jumper--next-index 2)))
+ (test-jumper-teardown))
+
+(ert-deftest test-jumper-remove-with-cancel ()
+ "Should return 'cancelled when index is -1."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "test")
+ (goto-char (point-min))
+ (jumper--do-store-location)
+ (let ((result (jumper--do-remove-location -1)))
+ (should (eq result 'cancelled))
+ (should (= jumper--next-index 1))))
+ (test-jumper-teardown))
+
+;;; Error Cases
+
+(ert-deftest test-jumper-remove-when-empty ()
+ "Should return 'no-locations when removing from empty list."
+ (test-jumper-setup)
+ (let ((result (jumper--do-remove-location 0)))
+ (should (eq result 'no-locations)))
+ (test-jumper-teardown))
+
+;;; Helper Function Tests
+
+(ert-deftest test-jumper-location-key-format ()
+ "Should generate unique location keys."
+ (with-temp-buffer
+ (insert "line 1\nline 2")
+ (goto-char (point-min))
+ (let ((key1 (jumper--location-key)))
+ (forward-line 1)
+ (let ((key2 (jumper--location-key)))
+ (should-not (string= key1 key2))
+ ;; Keys should contain buffer name and position info
+ (should (string-match-p ":" key1))
+ (should (string-match-p ":" key2))))))
+
+(ert-deftest test-jumper-register-available-p ()
+ "Should correctly report register availability."
+ (test-jumper-setup)
+ (should (jumper--register-available-p))
+ ;; Fill to capacity
+ (setq jumper--next-index jumper-max-locations)
+ (should-not (jumper--register-available-p))
+ (test-jumper-teardown))
+
+(ert-deftest test-jumper-format-location ()
+ "Should format location for display."
+ (test-jumper-setup)
+ (with-temp-buffer
+ (insert "test line with some content")
+ (goto-char (point-min))
+ (jumper--do-store-location)
+ (let ((formatted (jumper--format-location 0)))
+ (should formatted)
+ (should (string-match-p "\\[0\\]" formatted))
+ (should (string-match-p "test line" formatted))))
+ (test-jumper-teardown))
+
+(provide 'test-jumper)
+;;; test-jumper.el ends here
diff --git a/tests/test-lorem-optimum-benchmark.el b/tests/test-lorem-optimum-benchmark.el
new file mode 100644
index 00000000..d3ca2873
--- /dev/null
+++ b/tests/test-lorem-optimum-benchmark.el
@@ -0,0 +1,227 @@
+;;; test-lorem-optimum-benchmark.el --- Performance tests for lorem-optimum.el -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Benchmark and performance tests for the Markov chain implementation.
+;;
+;; These tests measure:
+;; - Learning time scaling with input size
+;; - Multiple learning operations (exposes key rebuild overhead)
+;; - Generation time scaling
+;; - Memory usage (hash table growth)
+;;
+;; Performance baseline targets (on modern hardware):
+;; - Learn 1000 words: < 10ms
+;; - Learn 10,000 words: < 100ms
+;; - 100 learn operations of 100 words each: < 500ms (current bottleneck!)
+;; - Generate 100 words: < 5ms
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Load the module
+(require 'lorem-optimum)
+
+;;; Benchmark Helpers
+
+(defun benchmark-time (func)
+ "Time execution of FUNC and return milliseconds."
+ (let ((start (current-time)))
+ (funcall func)
+ (let ((end (current-time)))
+ (* 1000.0 (float-time (time-subtract end start))))))
+
+(defun generate-test-text (word-count)
+ "Generate WORD-COUNT words of test text with some repetition."
+ (let ((words '("lorem" "ipsum" "dolor" "sit" "amet" "consectetur"
+ "adipiscing" "elit" "sed" "do" "eiusmod" "tempor"
+ "incididunt" "ut" "labore" "et" "dolore" "magna" "aliqua"))
+ (result '()))
+ (dotimes (i word-count)
+ (push (nth (mod i (length words)) words) result)
+ (when (zerop (mod i 10))
+ (push "." result)))
+ (mapconcat #'identity (nreverse result) " ")))
+
+(defun benchmark-report (name time-ms)
+ "Report benchmark NAME with TIME-MS."
+ (message "BENCHMARK [%s]: %.2f ms" name time-ms))
+
+;;; Learning Performance Tests
+
+(ert-deftest benchmark-learn-1k-words ()
+ "Benchmark learning 1000 words."
+ (let* ((text (generate-test-text 1000))
+ (chain (cj/markov-chain-create))
+ (time (benchmark-time
+ (lambda () (cj/markov-learn chain text)))))
+ (benchmark-report "Learn 1K words" time)
+ (should (< time 50.0)))) ; Should be < 50ms
+
+(ert-deftest benchmark-learn-10k-words ()
+ "Benchmark learning 10,000 words."
+ (let* ((text (generate-test-text 10000))
+ (chain (cj/markov-chain-create))
+ (time (benchmark-time
+ (lambda () (cj/markov-learn chain text)))))
+ (benchmark-report "Learn 10K words" time)
+ (should (< time 500.0)))) ; Should be < 500ms
+
+(ert-deftest benchmark-learn-100k-words ()
+ "Benchmark learning 100,000 words (stress test)."
+ :tags '(:slow)
+ (let* ((text (generate-test-text 100000))
+ (chain (cj/markov-chain-create))
+ (time (benchmark-time
+ (lambda () (cj/markov-learn chain text)))))
+ (benchmark-report "Learn 100K words" time)
+ ;; This may be slow due to key rebuild
+ (message "Hash table size: %d bigrams"
+ (hash-table-count (cj/markov-chain-map chain)))))
+
+;;; Multiple Learning Operations (Exposes Quadratic Behavior)
+
+(ert-deftest benchmark-multiple-learns-10x100 ()
+ "Benchmark 10 learn operations of 100 words each."
+ (let ((chain (cj/markov-chain-create))
+ (times '()))
+ (dotimes (i 10)
+ (let* ((text (generate-test-text 100))
+ (time (benchmark-time
+ (lambda () (cj/markov-learn chain text)))))
+ (push time times)))
+ (let ((total (apply #'+ times))
+ (avg (/ (apply #'+ times) 10.0))
+ (max-time (apply #'max times)))
+ (benchmark-report "10x learn 100 words - TOTAL" total)
+ (benchmark-report "10x learn 100 words - AVG" avg)
+ (benchmark-report "10x learn 100 words - MAX" max-time)
+ (message "Times: %S" (nreverse times))
+ ;; Note: Watch if later operations are slower (quadratic behavior)
+ (should (< total 100.0))))) ; Total should be < 100ms
+
+(ert-deftest benchmark-multiple-learns-100x100 ()
+ "Benchmark 100 learn operations of 100 words each (key rebuild overhead)."
+ :tags '(:slow)
+ (let ((chain (cj/markov-chain-create))
+ (times '())
+ (measurements '()))
+ (dotimes (i 100)
+ (let* ((text (generate-test-text 100))
+ (time (benchmark-time
+ (lambda () (cj/markov-learn chain text)))))
+ (push time times)
+ ;; Sample measurements every 10 iterations
+ (when (zerop (mod i 10))
+ (push (cons i time) measurements))))
+ (let ((total (apply #'+ times))
+ (avg (/ (apply #'+ times) 100.0))
+ (first-10-avg (/ (apply #'+ (last times 10)) 10.0))
+ (last-10-avg (/ (apply #'+ (seq-take times 10)) 10.0)))
+ (benchmark-report "100x learn 100 words - TOTAL" total)
+ (benchmark-report "100x learn 100 words - AVG" avg)
+ (benchmark-report "100x learn - First 10 AVG" first-10-avg)
+ (benchmark-report "100x learn - Last 10 AVG" last-10-avg)
+ (message "Sampled times (iteration, ms): %S" (nreverse measurements))
+ (message "Hash table size: %d bigrams"
+ (hash-table-count (cj/markov-chain-map chain)))
+ ;; This exposes the quadratic behavior: last operations much slower
+ (when (> last-10-avg (* 2.0 first-10-avg))
+ (message "WARNING: Learning slows down significantly over time!")
+ (message " First 10 avg: %.2f ms" first-10-avg)
+ (message " Last 10 avg: %.2f ms" last-10-avg)
+ (message " Ratio: %.1fx slower" (/ last-10-avg first-10-avg))))))
+
+;;; Generation Performance Tests
+
+(ert-deftest benchmark-generate-100-words ()
+ "Benchmark generating 100 words."
+ (let* ((text (generate-test-text 1000))
+ (chain (cj/markov-chain-create)))
+ (cj/markov-learn chain text)
+ (let ((time (benchmark-time
+ (lambda () (cj/markov-generate chain 100)))))
+ (benchmark-report "Generate 100 words" time)
+ (should (< time 20.0))))) ; Should be < 20ms
+
+(ert-deftest benchmark-generate-1000-words ()
+ "Benchmark generating 1000 words."
+ (let* ((text (generate-test-text 10000))
+ (chain (cj/markov-chain-create)))
+ (cj/markov-learn chain text)
+ (let ((time (benchmark-time
+ (lambda () (cj/markov-generate chain 1000)))))
+ (benchmark-report "Generate 1000 words" time)
+ (should (< time 100.0))))) ; Should be < 100ms
+
+;;; Tokenization Performance Tests
+
+(ert-deftest benchmark-tokenize-10k-words ()
+ "Benchmark tokenizing 10,000 words."
+ (let* ((text (generate-test-text 10000))
+ (time (benchmark-time
+ (lambda () (cj/markov-tokenize text)))))
+ (benchmark-report "Tokenize 10K words" time)
+ (should (< time 50.0)))) ; Tokenization should be fast
+
+;;; Memory/Size Tests
+
+(ert-deftest benchmark-chain-growth ()
+ "Measure hash table growth with increasing input."
+ (let ((chain (cj/markov-chain-create))
+ (sizes '()))
+ (dolist (word-count '(100 500 1000 5000 10000))
+ (let ((text (generate-test-text word-count)))
+ (cj/markov-learn chain text)
+ (let ((size (hash-table-count (cj/markov-chain-map chain))))
+ (push (cons word-count size) sizes)
+ (message "After %d words: %d unique bigrams" word-count size))))
+ (message "Growth pattern: %S" (nreverse sizes))))
+
+;;; Comparison: Tokenization vs Learning
+
+(ert-deftest benchmark-tokenize-vs-learn ()
+ "Compare tokenization time to total learning time."
+ (let* ((text (generate-test-text 5000))
+ (tokenize-time (benchmark-time
+ (lambda () (cj/markov-tokenize text))))
+ (chain (cj/markov-chain-create))
+ (learn-time (benchmark-time
+ (lambda () (cj/markov-learn chain text)))))
+ (benchmark-report "Tokenize 5K words" tokenize-time)
+ (benchmark-report "Learn 5K words (total)" learn-time)
+ (message "Tokenization is %.1f%% of total learning time"
+ (* 100.0 (/ tokenize-time learn-time)))))
+
+;;; Real-world Scenario
+
+(ert-deftest benchmark-realistic-usage ()
+ "Benchmark realistic usage: learn from multiple sources, generate paragraphs."
+ (let ((chain (cj/markov-chain-create))
+ (learn-total 0.0)
+ (gen-total 0.0))
+ ;; Simulate learning from 10 different sources
+ (dotimes (i 10)
+ (let ((text (generate-test-text 500)))
+ (setq learn-total
+ (+ learn-total
+ (benchmark-time (lambda () (cj/markov-learn chain text)))))))
+
+ ;; Generate 5 paragraphs
+ (dotimes (i 5)
+ (setq gen-total
+ (+ gen-total
+ (benchmark-time (lambda () (cj/markov-generate chain 50))))))
+
+ (benchmark-report "Realistic: 10 learns (500 words each)" learn-total)
+ (benchmark-report "Realistic: 5 generations (50 words each)" gen-total)
+ (benchmark-report "Realistic: TOTAL TIME" (+ learn-total gen-total))
+ (message "Final chain size: %d bigrams"
+ (hash-table-count (cj/markov-chain-map chain)))))
+
+(provide 'test-lorem-optimum-benchmark)
+;;; test-lorem-optimum-benchmark.el ends here
diff --git a/tests/test-lorem-optimum.el b/tests/test-lorem-optimum.el
new file mode 100644
index 00000000..ca2e52f4
--- /dev/null
+++ b/tests/test-lorem-optimum.el
@@ -0,0 +1,242 @@
+;;; test-lorem-optimum.el --- Tests for lorem-optimum.el -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for lorem-optimum.el Markov chain text generation.
+;;
+;; Tests cover:
+;; - Tokenization
+;; - Learning and chain building
+;; - Text generation
+;; - Capitalization fixing
+;; - Token joining
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Load the module
+(require 'lorem-optimum)
+
+;;; Test Helpers
+
+(defun test-chain ()
+ "Create a fresh test chain."
+ (cj/markov-chain-create))
+
+(defun test-learn (text)
+ "Create a chain and learn TEXT."
+ (let ((chain (test-chain)))
+ (cj/markov-learn chain text)
+ chain))
+
+;;; Tokenization Tests
+
+(ert-deftest test-tokenize-simple ()
+ "Should tokenize simple words."
+ (let ((result (cj/markov-tokenize "hello world")))
+ (should (equal result '("hello" "world")))))
+
+(ert-deftest test-tokenize-with-punctuation ()
+ "Should separate punctuation as tokens."
+ (let ((result (cj/markov-tokenize "Hello, world!")))
+ (should (equal result '("Hello" "," "world" "!")))))
+
+(ert-deftest test-tokenize-multiple-spaces ()
+ "Should handle multiple spaces."
+ (let ((result (cj/markov-tokenize "hello world")))
+ (should (equal result '("hello" "world")))))
+
+(ert-deftest test-tokenize-newlines ()
+ "Should handle newlines as whitespace."
+ (let ((result (cj/markov-tokenize "hello\nworld")))
+ (should (equal result '("hello" "world")))))
+
+(ert-deftest test-tokenize-mixed-punctuation ()
+ "Should tokenize complex punctuation."
+ (let ((result (cj/markov-tokenize "one, two; three.")))
+ (should (equal result '("one" "," "two" ";" "three" ".")))))
+
+(ert-deftest test-tokenize-empty ()
+ "Should handle empty string."
+ (let ((result (cj/markov-tokenize "")))
+ (should (null result))))
+
+(ert-deftest test-tokenize-whitespace-only ()
+ "Should return nil for whitespace only."
+ (let ((result (cj/markov-tokenize " \n\t ")))
+ (should (null result))))
+
+;;; Markov Learn Tests
+
+(ert-deftest test-learn-basic ()
+ "Should learn simple text."
+ (let ((chain (test-learn "one two three four")))
+ (should (cj/markov-chain-p chain))
+ (should (> (hash-table-count (cj/markov-chain-map chain)) 0))))
+
+(ert-deftest test-learn-creates-bigrams ()
+ "Should create bigram mappings."
+ (let ((chain (test-learn "one two three")))
+ (should (gethash '("one" "two") (cj/markov-chain-map chain)))))
+
+(ert-deftest test-learn-stores-following-word ()
+ "Should store following word for bigram."
+ (let ((chain (test-learn "one two three")))
+ (should (member "three" (gethash '("one" "two") (cj/markov-chain-map chain))))))
+
+(ert-deftest test-learn-builds-keys-list ()
+ "Should build keys list lazily when accessed."
+ (let ((chain (test-learn "one two three four")))
+ ;; Keys are built lazily, so initially nil
+ (should (null (cj/markov-chain-keys chain)))
+ ;; After calling random-key, keys should be built
+ (cj/markov-random-key chain)
+ (should (> (length (cj/markov-chain-keys chain)) 0))))
+
+(ert-deftest test-learn-repeated-patterns ()
+ "Should accumulate repeated patterns."
+ (let ((chain (test-learn "one two three one two four")))
+ (let ((nexts (gethash '("one" "two") (cj/markov-chain-map chain))))
+ (should (= (length nexts) 2))
+ (should (member "three" nexts))
+ (should (member "four" nexts)))))
+
+(ert-deftest test-learn-incremental ()
+ "Should support incremental learning."
+ (let ((chain (test-chain)))
+ (cj/markov-learn chain "one two three")
+ (cj/markov-learn chain "four five six")
+ (should (> (hash-table-count (cj/markov-chain-map chain)) 0))))
+
+;;; Token Joining Tests
+
+(ert-deftest test-join-simple-words ()
+ "Should join words with spaces."
+ (let ((result (cj/markov-join-tokens '("hello" "world"))))
+ (should (string-match-p "^Hello world" result))))
+
+(ert-deftest test-join-with-punctuation ()
+ "Should attach punctuation without spaces."
+ (let ((result (cj/markov-join-tokens '("hello" "," "world"))))
+ (should (string-match-p "Hello, world" result))))
+
+(ert-deftest test-join-capitalizes-first ()
+ "Should capitalize first word."
+ (let ((result (cj/markov-join-tokens '("hello" "world"))))
+ (should (string-match-p "^H" result))))
+
+(ert-deftest test-join-adds-period ()
+ "Should add period if missing."
+ (let ((result (cj/markov-join-tokens '("hello" "world"))))
+ (should (string-match-p "\\.$" result))))
+
+(ert-deftest test-join-preserves-existing-period ()
+ "Should not double-add period."
+ (let ((result (cj/markov-join-tokens '("hello" "world" "."))))
+ (should (string-match-p "\\.$" result))
+ (should-not (string-match-p "\\.\\.$" result))))
+
+(ert-deftest test-join-empty-tokens ()
+ "Should handle empty token list."
+ (let ((result (cj/markov-join-tokens '())))
+ (should (equal result "."))))
+
+;;; Capitalization Tests
+
+(ert-deftest test-capitalize-first-word ()
+ "Should capitalize first word."
+ (let ((result (cj/markov-fix-capitalization "hello world")))
+ (should (string-match-p "^Hello" result))))
+
+(ert-deftest test-capitalize-after-period ()
+ "Should capitalize after period."
+ (let ((result (cj/markov-fix-capitalization "hello. world")))
+ (should (string-match-p "Hello\\. World" result))))
+
+(ert-deftest test-capitalize-after-exclamation ()
+ "Should capitalize after exclamation."
+ (let ((result (cj/markov-fix-capitalization "hello! world")))
+ (should (string-match-p "Hello! World" result))))
+
+(ert-deftest test-capitalize-after-question ()
+ "Should capitalize after question mark."
+ (let ((result (cj/markov-fix-capitalization "hello? world")))
+ (should (string-match-p "Hello\\? World" result))))
+
+(ert-deftest test-capitalize-skip-non-alpha ()
+ "Should skip non-alphabetic tokens."
+ (let ((result (cj/markov-fix-capitalization "hello. 123 world")))
+ (should (string-match-p "123" result))))
+
+(ert-deftest test-capitalize-multiple-sentences ()
+ "Should capitalize all sentences."
+ (let ((result (cj/markov-fix-capitalization "first. second. third")))
+ (should (string-match-p "First\\. Second\\. Third" result))))
+
+;;; Generation Tests (deterministic with fixed chain)
+
+(ert-deftest test-generate-produces-output ()
+ "Should generate non-empty output."
+ (let ((chain (test-learn "Lorem ipsum dolor sit amet consectetur adipiscing elit")))
+ (let ((result (cj/markov-generate chain 5)))
+ (should (stringp result))
+ (should (> (length result) 0)))))
+
+(ert-deftest test-generate-empty-chain ()
+ "Should handle empty chain gracefully."
+ (let ((chain (test-chain)))
+ (let ((result (cj/markov-generate chain 5)))
+ (should (or (null result) (string-empty-p result))))))
+
+(ert-deftest test-generate-respects-start ()
+ "Should use provided start state if available."
+ (let ((chain (test-learn "Lorem ipsum dolor sit amet")))
+ (let ((result (cj/markov-generate chain 3 '("Lorem" "ipsum"))))
+ (should (stringp result))
+ ;; Should start with Lorem or similar
+ (should (> (length result) 0)))))
+
+;;; Integration Tests
+
+(ert-deftest test-full-workflow ()
+ "Should complete full learn-generate workflow."
+ (let ((chain (test-chain)))
+ (cj/markov-learn chain "The quick brown fox jumps over the lazy dog")
+ (let ((result (cj/markov-generate chain 8)))
+ (should (stringp result))
+ (should (> (length result) 0))
+ (should (string-match-p "^[A-Z]" result))
+ (should (string-match-p "[.!?]$" result)))))
+
+(ert-deftest test-latin-like-output ()
+ "Should generate Latin-like text from Latin input."
+ (let ((chain (test-chain)))
+ (cj/markov-learn chain "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.")
+ (let ((result (cj/markov-generate chain 10)))
+ (should (stringp result))
+ (should (> (length result) 10)))))
+
+;;; Edge Cases
+
+(ert-deftest test-learn-short-text ()
+ "Should handle text shorter than trigram."
+ (let ((chain (test-learn "one two")))
+ (should (cj/markov-chain-p chain))))
+
+(ert-deftest test-learn-single-word ()
+ "Should handle single word."
+ (let ((chain (test-learn "word")))
+ (should (cj/markov-chain-p chain))))
+
+(ert-deftest test-generate-requested-count-small ()
+ "Should handle small generation count."
+ (let ((chain (test-learn "one two three four five")))
+ (let ((result (cj/markov-generate chain 2)))
+ (should (stringp result)))))
+
+(provide 'test-lorem-optimum)
+;;; test-lorem-optimum.el ends here
diff --git a/tests/test-org-contacts-parse-email.el b/tests/test-org-contacts-parse-email.el
new file mode 100644
index 00000000..37e79fba
--- /dev/null
+++ b/tests/test-org-contacts-parse-email.el
@@ -0,0 +1,219 @@
+;;; test-org-contacts-parse-email.el --- Tests for cj/--parse-email-string -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--parse-email-string function from org-contacts-config.el
+;;
+;; This function parses a string containing one or more email addresses
+;; separated by commas, semicolons, or spaces, and formats them as
+;; "Name <email>" strings.
+;;
+;; Examples:
+;; Input: name="John Doe", email-string="john@example.com"
+;; Output: '("John Doe <john@example.com>")
+;;
+;; Input: name="Jane Smith", email-string="jane@work.com, jane@home.com"
+;; Output: '("Jane Smith <jane@work.com>" "Jane Smith <jane@home.com>")
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Now load the actual production module
+(require 'org-contacts-config)
+
+;;; Test Helpers
+
+(defun test-parse-email (name email-string)
+ "Test cj/--parse-email-string with NAME and EMAIL-STRING.
+Returns the formatted email list."
+ (cj/--parse-email-string name email-string))
+
+;;; Normal Cases - Single Email
+
+(ert-deftest test-parse-single-email ()
+ "Should format single email address."
+ (let ((result (test-parse-email "John Doe" "john@example.com")))
+ (should (equal result '("John Doe <john@example.com>")))))
+
+(ert-deftest test-parse-single-email-with-subdomain ()
+ "Should handle email with subdomain."
+ (let ((result (test-parse-email "Jane Smith" "jane@mail.company.com")))
+ (should (equal result '("Jane Smith <jane@mail.company.com>")))))
+
+(ert-deftest test-parse-email-with-numbers ()
+ "Should handle email containing numbers."
+ (let ((result (test-parse-email "User 123" "user123@test.com")))
+ (should (equal result '("User 123 <user123@test.com>")))))
+
+(ert-deftest test-parse-email-with-dots ()
+ "Should handle email with dots in local part."
+ (let ((result (test-parse-email "Bob Jones" "bob.jones@example.com")))
+ (should (equal result '("Bob Jones <bob.jones@example.com>")))))
+
+(ert-deftest test-parse-email-with-hyphen ()
+ "Should handle email with hyphens."
+ (let ((result (test-parse-email "Alice Brown" "alice-brown@test-domain.com")))
+ (should (equal result '("Alice Brown <alice-brown@test-domain.com>")))))
+
+;;; Normal Cases - Multiple Emails with Different Separators
+
+(ert-deftest test-parse-two-emails-comma ()
+ "Should parse two emails separated by comma."
+ (let ((result (test-parse-email "John Doe" "john@work.com, john@home.com")))
+ (should (equal result '("John Doe <john@work.com>" "John Doe <john@home.com>")))))
+
+(ert-deftest test-parse-two-emails-semicolon ()
+ "Should parse two emails separated by semicolon."
+ (let ((result (test-parse-email "Jane Smith" "jane@work.com; jane@home.com")))
+ (should (equal result '("Jane Smith <jane@work.com>" "Jane Smith <jane@home.com>")))))
+
+(ert-deftest test-parse-two-emails-space ()
+ "Should parse two emails separated by space."
+ (let ((result (test-parse-email "Bob Jones" "bob@work.com bob@home.com")))
+ (should (equal result '("Bob Jones <bob@work.com>" "Bob Jones <bob@home.com>")))))
+
+(ert-deftest test-parse-three-emails-mixed-separators ()
+ "Should parse emails with mixed separators."
+ (let ((result (test-parse-email "Alice" "alice@a.com, alice@b.com; alice@c.com")))
+ (should (equal result '("Alice <alice@a.com>" "Alice <alice@b.com>" "Alice <alice@c.com>")))))
+
+(ert-deftest test-parse-multiple-emails-with-spaces ()
+ "Should parse comma-separated emails with spaces."
+ (let ((result (test-parse-email "User" "a@test.com , b@test.com , c@test.com")))
+ (should (equal result '("User <a@test.com>" "User <b@test.com>" "User <c@test.com>")))))
+
+;;; Normal Cases - Whitespace Handling
+
+(ert-deftest test-parse-email-leading-whitespace ()
+ "Should trim leading whitespace from email."
+ (let ((result (test-parse-email "John" " john@example.com")))
+ (should (equal result '("John <john@example.com>")))))
+
+(ert-deftest test-parse-email-trailing-whitespace ()
+ "Should trim trailing whitespace from email."
+ (let ((result (test-parse-email "Jane" "jane@example.com ")))
+ (should (equal result '("Jane <jane@example.com>")))))
+
+(ert-deftest test-parse-email-surrounding-whitespace ()
+ "Should trim surrounding whitespace from email."
+ (let ((result (test-parse-email "Bob" " bob@example.com ")))
+ (should (equal result '("Bob <bob@example.com>")))))
+
+(ert-deftest test-parse-emails-with-tabs ()
+ "Should handle emails separated by tabs."
+ (let ((result (test-parse-email "User" "a@test.com\tb@test.com")))
+ (should (equal result '("User <a@test.com>" "User <b@test.com>")))))
+
+;;; Edge Cases - Empty and Nil
+
+(ert-deftest test-parse-nil-email-string ()
+ "Should return nil for nil email string."
+ (let ((result (test-parse-email "John Doe" nil)))
+ (should (null result))))
+
+(ert-deftest test-parse-empty-email-string ()
+ "Should return nil for empty email string."
+ (let ((result (test-parse-email "Jane Smith" "")))
+ (should (null result))))
+
+(ert-deftest test-parse-whitespace-only ()
+ "Should return nil for whitespace-only string."
+ (let ((result (test-parse-email "Bob Jones" " ")))
+ (should (null result))))
+
+(ert-deftest test-parse-tabs-only ()
+ "Should return nil for tabs-only string."
+ (let ((result (test-parse-email "Alice" "\t\t\t")))
+ (should (null result))))
+
+(ert-deftest test-parse-mixed-whitespace-only ()
+ "Should return nil for mixed whitespace."
+ (let ((result (test-parse-email "User" " \t \n ")))
+ (should (null result))))
+
+;;; Edge Cases - Multiple Consecutive Separators
+
+(ert-deftest test-parse-multiple-commas ()
+ "Should handle multiple consecutive commas."
+ (let ((result (test-parse-email "John" "john@a.com,,,john@b.com")))
+ (should (equal result '("John <john@a.com>" "John <john@b.com>")))))
+
+(ert-deftest test-parse-multiple-semicolons ()
+ "Should handle multiple consecutive semicolons."
+ (let ((result (test-parse-email "Jane" "jane@a.com;;;jane@b.com")))
+ (should (equal result '("Jane <jane@a.com>" "Jane <jane@b.com>")))))
+
+(ert-deftest test-parse-multiple-spaces ()
+ "Should handle multiple consecutive spaces."
+ (let ((result (test-parse-email "Bob" "bob@a.com bob@b.com")))
+ (should (equal result '("Bob <bob@a.com>" "Bob <bob@b.com>")))))
+
+(ert-deftest test-parse-mixed-multiple-separators ()
+ "Should handle mixed consecutive separators."
+ (let ((result (test-parse-email "User" "a@test.com , ; b@test.com")))
+ (should (equal result '("User <a@test.com>" "User <b@test.com>")))))
+
+;;; Edge Cases - Special Name Formats
+
+(ert-deftest test-parse-name-with-title ()
+ "Should handle name with title."
+ (let ((result (test-parse-email "Dr. John Smith" "john@example.com")))
+ (should (equal result '("Dr. John Smith <john@example.com>")))))
+
+(ert-deftest test-parse-name-with-suffix ()
+ "Should handle name with suffix."
+ (let ((result (test-parse-email "John Doe Jr." "john@example.com")))
+ (should (equal result '("John Doe Jr. <john@example.com>")))))
+
+(ert-deftest test-parse-name-with-special-chars ()
+ "Should handle name with special characters."
+ (let ((result (test-parse-email "O'Brien, Patrick" "patrick@example.com")))
+ (should (equal result '("O'Brien, Patrick <patrick@example.com>")))))
+
+(ert-deftest test-parse-unicode-name ()
+ "Should handle Unicode characters in name."
+ (let ((result (test-parse-email "José García" "jose@example.com")))
+ (should (equal result '("José García <jose@example.com>")))))
+
+;;; Edge Cases - Special Email Formats
+
+(ert-deftest test-parse-email-with-plus ()
+ "Should handle email with plus sign."
+ (let ((result (test-parse-email "User" "user+tag@example.com")))
+ (should (equal result '("User <user+tag@example.com>")))))
+
+(ert-deftest test-parse-email-with-underscore ()
+ "Should handle email with underscore."
+ (let ((result (test-parse-email "User" "user_name@example.com")))
+ (should (equal result '("User <user_name@example.com>")))))
+
+(ert-deftest test-parse-very-long-email ()
+ "Should handle very long email address."
+ (let* ((long-local (make-string 50 ?a))
+ (email (concat long-local "@example.com"))
+ (result (test-parse-email "User" email)))
+ (should (equal result (list (format "User <%s>" email))))))
+
+;;; Integration Tests
+
+(ert-deftest test-parse-realistic-contact ()
+ "Should parse realistic contact with multiple emails."
+ (let ((result (test-parse-email "John Doe" "john.doe@company.com, jdoe@personal.com")))
+ (should (equal result '("John Doe <john.doe@company.com>" "John Doe <jdoe@personal.com>")))))
+
+(ert-deftest test-parse-messy-input ()
+ "Should handle messy real-world input."
+ (let ((result (test-parse-email "Jane Smith" " jane@work.com ; jane@home.com,jane@mobile.com ")))
+ (should (equal result '("Jane Smith <jane@work.com>" "Jane Smith <jane@home.com>" "Jane Smith <jane@mobile.com>")))))
+
+(ert-deftest test-parse-single-with-extra-separators ()
+ "Should handle single email with trailing separators."
+ (let ((result (test-parse-email "Bob" "bob@example.com;;;")))
+ (should (equal result '("Bob <bob@example.com>")))))
+
+(provide 'test-org-contacts-parse-email)
+;;; test-org-contacts-parse-email.el ends here
diff --git a/tests/test-org-roam-config-demote.el b/tests/test-org-roam-config-demote.el
new file mode 100644
index 00000000..98cc8244
--- /dev/null
+++ b/tests/test-org-roam-config-demote.el
@@ -0,0 +1,183 @@
+;;; test-org-roam-config-demote.el --- Tests for cj/--demote-org-subtree -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--demote-org-subtree function from org-roam-config.el
+;;
+;; This function demotes org subtree content from one level to another.
+;; All headings in the tree are adjusted proportionally, with a minimum level of 1.
+;;
+;; Examples:
+;; Input: "*** Heading\n**** Sub", from: 3, to: 1
+;; Output: "* Heading\n** Sub"
+;;
+;; Input: "** Heading\n*** Sub", from: 2, to: 1
+;; Output: "* Heading\n** Sub"
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Now load the actual production module
+(require 'org-roam-config)
+
+;;; Test Helpers
+
+(defun test-demote (content from-level to-level)
+ "Test cj/--demote-org-subtree on CONTENT.
+FROM-LEVEL is the current top level, TO-LEVEL is the desired top level.
+Returns the demoted content."
+ (cj/--demote-org-subtree content from-level to-level))
+
+;;; Normal Cases - Single Heading
+
+(ert-deftest test-demote-level2-to-level1 ()
+ "Should demote level 2 heading to level 1."
+ (let ((result (test-demote "** Heading\n" 2 1)))
+ (should (string= result "* Heading\n"))))
+
+(ert-deftest test-demote-level3-to-level1 ()
+ "Should demote level 3 heading to level 1."
+ (let ((result (test-demote "*** Heading\n" 3 1)))
+ (should (string= result "* Heading\n"))))
+
+(ert-deftest test-demote-level4-to-level1 ()
+ "Should demote level 4 heading to level 1."
+ (let ((result (test-demote "**** Heading\n" 4 1)))
+ (should (string= result "* Heading\n"))))
+
+(ert-deftest test-demote-level3-to-level2 ()
+ "Should demote level 3 heading to level 2."
+ (let ((result (test-demote "*** Heading\n" 3 2)))
+ (should (string= result "** Heading\n"))))
+
+;;; Normal Cases - Multiple Headings at Same Level
+
+(ert-deftest test-demote-multiple-same-level ()
+ "Should demote multiple headings at same level."
+ (let ((result (test-demote "** First\n** Second\n** Third\n" 2 1)))
+ (should (string= result "* First\n* Second\n* Third\n"))))
+
+;;; Normal Cases - Hierarchical Structure
+
+(ert-deftest test-demote-with-subheading ()
+ "Should demote heading and subheading proportionally."
+ (let ((result (test-demote "** Heading\n*** Subheading\n" 2 1)))
+ (should (string= result "* Heading\n** Subheading\n"))))
+
+(ert-deftest test-demote-three-levels ()
+ "Should demote three-level hierarchy."
+ (let ((result (test-demote "** Main\n*** Sub\n**** SubSub\n" 2 1)))
+ (should (string= result "* Main\n** Sub\n*** SubSub\n"))))
+
+(ert-deftest test-demote-complex-hierarchy ()
+ "Should demote complex hierarchy maintaining relative structure."
+ (let ((result (test-demote "*** Top\n**** Sub1\n***** Deep\n**** Sub2\n" 3 1)))
+ (should (string= result "* Top\n** Sub1\n*** Deep\n** Sub2\n"))))
+
+;;; Normal Cases - With Content
+
+(ert-deftest test-demote-heading-with-text ()
+ "Should demote heading preserving body text."
+ (let ((result (test-demote "** Heading\nBody text\n" 2 1)))
+ (should (string= result "* Heading\nBody text\n"))))
+
+(ert-deftest test-demote-with-properties ()
+ "Should demote heading preserving properties."
+ (let ((result (test-demote "** Heading\n:PROPERTIES:\n:ID: 123\n:END:\n" 2 1)))
+ (should (string= result "* Heading\n:PROPERTIES:\n:ID: 123\n:END:\n"))))
+
+(ert-deftest test-demote-with-mixed-content ()
+ "Should demote headings preserving all content."
+ (let ((result (test-demote "** H1\nText\n*** H2\nMore text\n" 2 1)))
+ (should (string= result "* H1\nText\n** H2\nMore text\n"))))
+
+;;; Boundary Cases - No Demotion Needed
+
+(ert-deftest test-demote-same-level ()
+ "Should return content unchanged when from equals to."
+ (let ((result (test-demote "* Heading\n" 1 1)))
+ (should (string= result "* Heading\n"))))
+
+(ert-deftest test-demote-promote-ignored ()
+ "Should return content unchanged when to > from (promotion)."
+ (let ((result (test-demote "* Heading\n" 1 2)))
+ (should (string= result "* Heading\n"))))
+
+;;; Boundary Cases - Minimum Level
+
+(ert-deftest test-demote-respects-minimum-level ()
+ "Should not demote below level 1."
+ (let ((result (test-demote "** Main\n*** Sub\n" 2 1)))
+ (should (string= result "* Main\n** Sub\n"))
+ ;; Sub went from 3 to 2, not below 1
+ (should (string-match-p "^\\*\\* Sub" result))))
+
+(ert-deftest test-demote-deep-hierarchy-min-level ()
+ "Should respect minimum level for deep hierarchies."
+ (let ((result (test-demote "**** L4\n***** L5\n****** L6\n" 4 1)))
+ (should (string= result "* L4\n** L5\n*** L6\n"))))
+
+;;; Boundary Cases - Empty and Edge Cases
+
+(ert-deftest test-demote-empty-string ()
+ "Should handle empty string."
+ (let ((result (test-demote "" 2 1)))
+ (should (string= result ""))))
+
+(ert-deftest test-demote-no-headings ()
+ "Should return non-heading content unchanged."
+ (let ((result (test-demote "Just plain text\nNo headings here\n" 2 1)))
+ (should (string= result "Just plain text\nNo headings here\n"))))
+
+(ert-deftest test-demote-heading-without-space ()
+ "Should not match headings without space after stars."
+ (let ((result (test-demote "**Not a heading\n** Real Heading\n" 2 1)))
+ (should (string= result "**Not a heading\n* Real Heading\n"))))
+
+;;; Edge Cases - Special Heading Content
+
+(ert-deftest test-demote-heading-with-tags ()
+ "Should demote heading preserving tags."
+ (let ((result (test-demote "** Heading :tag1:tag2:\n" 2 1)))
+ (should (string= result "* Heading :tag1:tag2:\n"))))
+
+(ert-deftest test-demote-heading-with-todo ()
+ "Should demote heading preserving TODO keyword."
+ (let ((result (test-demote "** TODO Task\n" 2 1)))
+ (should (string= result "* TODO Task\n"))))
+
+(ert-deftest test-demote-heading-with-priority ()
+ "Should demote heading preserving priority."
+ (let ((result (test-demote "** [#A] Important\n" 2 1)))
+ (should (string= result "* [#A] Important\n"))))
+
+;;; Edge Cases - Whitespace
+
+(ert-deftest test-demote-preserves-indentation ()
+ "Should preserve indentation in body text."
+ (let ((result (test-demote "** Heading\n Indented text\n" 2 1)))
+ (should (string= result "* Heading\n Indented text\n"))))
+
+(ert-deftest test-demote-multiple-spaces-after-stars ()
+ "Should handle multiple spaces after stars."
+ (let ((result (test-demote "** Heading\n" 2 1)))
+ (should (string= result "* Heading\n"))))
+
+;;; Edge Cases - Large Demotion
+
+(ert-deftest test-demote-large-level-difference ()
+ "Should handle large level differences."
+ (let ((result (test-demote "****** Level 6\n******* Level 7\n" 6 1)))
+ (should (string= result "* Level 6\n** Level 7\n"))))
+
+(ert-deftest test-demote-to-level-2 ()
+ "Should demote to level 2 when specified."
+ (let ((result (test-demote "***** Level 5\n****** Level 6\n" 5 2)))
+ (should (string= result "** Level 5\n*** Level 6\n"))))
+
+(provide 'test-org-roam-config-demote)
+;;; test-org-roam-config-demote.el ends here
diff --git a/tests/test-org-roam-config-format.el b/tests/test-org-roam-config-format.el
new file mode 100644
index 00000000..e9378b7a
--- /dev/null
+++ b/tests/test-org-roam-config-format.el
@@ -0,0 +1,151 @@
+;;; test-org-roam-config-format.el --- Tests for cj/--format-roam-node -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--format-roam-node function from org-roam-config.el
+;;
+;; This function formats org-roam node file content with title, node-id, and body content.
+;; It creates a complete org-roam file with properties, title, category, and filetags.
+;;
+;; Example:
+;; Input: title: "My Note", node-id: "abc123", content: "* Content\n"
+;; Output: Full org-roam file with metadata and content
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Now load the actual production module
+(require 'org-roam-config)
+
+;;; Test Helpers
+
+(defun test-format (title node-id content)
+ "Test cj/--format-roam-node with TITLE, NODE-ID, and CONTENT.
+Returns the formatted file content."
+ (cj/--format-roam-node title node-id content))
+
+;;; Normal Cases
+
+(ert-deftest test-format-simple-node ()
+ "Should format simple node with all components."
+ (let ((result (test-format "Test Title" "id-123" "* Content\n")))
+ (should (string-match-p ":PROPERTIES:" result))
+ (should (string-match-p ":ID: id-123" result))
+ (should (string-match-p "#\\+TITLE: Test Title" result))
+ (should (string-match-p "#\\+CATEGORY: Test Title" result))
+ (should (string-match-p "#\\+FILETAGS: Topic" result))
+ (should (string-match-p "\\* Content" result))))
+
+(ert-deftest test-format-properties-first ()
+ "Should place properties at the beginning."
+ (let ((result (test-format "Title" "id" "content")))
+ (should (string-prefix-p ":PROPERTIES:\n" result))))
+
+(ert-deftest test-format-id-after-properties ()
+ "Should place ID in properties block."
+ (let ((result (test-format "Title" "test-id-456" "content")))
+ (should (string-match-p ":PROPERTIES:\n:ID: test-id-456\n:END:" result))))
+
+(ert-deftest test-format-title-after-properties ()
+ "Should place title after properties."
+ (let ((result (test-format "My Title" "id" "content")))
+ (should (string-match-p ":END:\n#\\+TITLE: My Title\n" result))))
+
+(ert-deftest test-format-category-matches-title ()
+ "Should set category to match title."
+ (let ((result (test-format "Project Name" "id" "content")))
+ (should (string-match-p "#\\+TITLE: Project Name\n#\\+CATEGORY: Project Name\n" result))))
+
+(ert-deftest test-format-filetags-topic ()
+ "Should set filetags to Topic."
+ (let ((result (test-format "Title" "id" "content")))
+ (should (string-match-p "#\\+FILETAGS: Topic\n" result))))
+
+(ert-deftest test-format-content-at-end ()
+ "Should place content after metadata."
+ (let ((result (test-format "Title" "id" "* Heading\nBody text\n")))
+ (should (string-suffix-p "* Heading\nBody text\n" result))))
+
+;;; Edge Cases - Various Titles
+
+(ert-deftest test-format-title-with-spaces ()
+ "Should handle title with spaces."
+ (let ((result (test-format "Multi Word Title" "id" "content")))
+ (should (string-match-p "#\\+TITLE: Multi Word Title" result))
+ (should (string-match-p "#\\+CATEGORY: Multi Word Title" result))))
+
+(ert-deftest test-format-title-with-punctuation ()
+ "Should handle title with punctuation."
+ (let ((result (test-format "Title: With, Punctuation!" "id" "content")))
+ (should (string-match-p "#\\+TITLE: Title: With, Punctuation!" result))))
+
+(ert-deftest test-format-title-with-numbers ()
+ "Should handle title with numbers."
+ (let ((result (test-format "Version 2.0" "id" "content")))
+ (should (string-match-p "#\\+TITLE: Version 2\\.0" result))))
+
+;;; Edge Cases - Various Node IDs
+
+(ert-deftest test-format-uuid-style-id ()
+ "Should handle UUID-style ID."
+ (let ((result (test-format "Title" "a1b2c3d4-e5f6-7890-abcd-ef1234567890" "content")))
+ (should (string-match-p ":ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890" result))))
+
+(ert-deftest test-format-short-id ()
+ "Should handle short ID."
+ (let ((result (test-format "Title" "1" "content")))
+ (should (string-match-p ":ID: 1" result))))
+
+(ert-deftest test-format-long-id ()
+ "Should handle long ID."
+ (let* ((long-id (make-string 100 ?a))
+ (result (test-format "Title" long-id "content")))
+ (should (string-match-p (concat ":ID: " long-id) result))))
+
+;;; Edge Cases - Various Content
+
+(ert-deftest test-format-empty-content ()
+ "Should handle empty content."
+ (let ((result (test-format "Title" "id" "")))
+ (should (string-suffix-p "#+FILETAGS: Topic\n\n" result))))
+
+(ert-deftest test-format-multiline-content ()
+ "Should handle multiline content."
+ (let ((result (test-format "Title" "id" "* H1\nText\n** H2\nMore\n")))
+ (should (string-suffix-p "* H1\nText\n** H2\nMore\n" result))))
+
+(ert-deftest test-format-content-with-properties ()
+ "Should handle content that already has properties."
+ (let ((result (test-format "Title" "id" "* Heading\n:PROPERTIES:\n:CUSTOM: value\n:END:\n")))
+ (should (string-match-p ":CUSTOM: value" result))))
+
+;;; Integration Tests - Structure
+
+(ert-deftest test-format-complete-structure ()
+ "Should create proper org-roam file structure."
+ (let ((result (test-format "My Note" "abc-123" "* Content\n")))
+ ;; Check order of components
+ (should (< (string-match ":PROPERTIES:" result)
+ (string-match ":ID:" result)))
+ (should (< (string-match ":ID:" result)
+ (string-match ":END:" result)))
+ (should (< (string-match ":END:" result)
+ (string-match "#\\+TITLE:" result)))
+ (should (< (string-match "#\\+TITLE:" result)
+ (string-match "#\\+CATEGORY:" result)))
+ (should (< (string-match "#\\+CATEGORY:" result)
+ (string-match "#\\+FILETAGS:" result)))
+ (should (< (string-match "#\\+FILETAGS:" result)
+ (string-match "\\* Content" result)))))
+
+(ert-deftest test-format-double-newline-after-metadata ()
+ "Should have double newline between metadata and content."
+ (let ((result (test-format "Title" "id" "* Content")))
+ (should (string-match-p "#\\+FILETAGS: Topic\n\n\\* Content" result))))
+
+(provide 'test-org-roam-config-format)
+;;; test-org-roam-config-format.el ends here
diff --git a/tests/test-org-roam-config-link-description.el b/tests/test-org-roam-config-link-description.el
new file mode 100644
index 00000000..06321b8f
--- /dev/null
+++ b/tests/test-org-roam-config-link-description.el
@@ -0,0 +1,188 @@
+;;; test-org-roam-config-link-description.el --- Tests for cj/org-link-get-description -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/org-link-get-description function from org-roam-config.el
+;;
+;; This function extracts the description from an org link, or returns the text unchanged.
+;; If TEXT contains an org link like [[url][description]], it returns description.
+;; If TEXT contains multiple links, only the first one is processed.
+;; Otherwise it returns TEXT unchanged.
+;;
+;; Examples:
+;; Input: "[[https://example.com][Example Site]]"
+;; Output: "Example Site"
+;;
+;; Input: "[[https://example.com]]"
+;; Output: "https://example.com"
+;;
+;; Input: "Plain text"
+;; Output: "Plain text"
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Now load the actual production module
+(require 'org-roam-config)
+
+;;; Test Helpers
+
+(defun test-link-description (text)
+ "Test cj/org-link-get-description on TEXT.
+Returns the extracted description or text unchanged."
+ (cj/org-link-get-description text))
+
+;;; Normal Cases - Link with Description
+
+(ert-deftest test-link-with-description ()
+ "Should extract description from link with description."
+ (let ((result (test-link-description "[[https://example.com][Example Site]]")))
+ (should (string= result "Example Site"))))
+
+(ert-deftest test-link-with-multiword-description ()
+ "Should extract multi-word description."
+ (let ((result (test-link-description "[[url][Multiple Word Description]]")))
+ (should (string= result "Multiple Word Description"))))
+
+(ert-deftest test-link-with-special-chars-in-description ()
+ "Should extract description with special characters."
+ (let ((result (test-link-description "[[url][Description: with, punctuation!]]")))
+ (should (string= result "Description: with, punctuation!"))))
+
+(ert-deftest test-link-file-path-with-description ()
+ "Should extract description from file link."
+ (let ((result (test-link-description "[[file:~/document.pdf][My Document]]")))
+ (should (string= result "My Document"))))
+
+(ert-deftest test-link-with-numbers-in-description ()
+ "Should extract description containing numbers."
+ (let ((result (test-link-description "[[url][Chapter 42]]")))
+ (should (string= result "Chapter 42"))))
+
+;;; Normal Cases - Link without Description
+
+(ert-deftest test-link-without-description-url ()
+ "Should return URL when no description is present."
+ (let ((result (test-link-description "[[https://example.com]]")))
+ (should (string= result "https://example.com"))))
+
+(ert-deftest test-link-without-description-file ()
+ "Should return file path when no description."
+ (let ((result (test-link-description "[[file:~/notes.org]]")))
+ (should (string= result "file:~/notes.org"))))
+
+(ert-deftest test-link-without-description-id ()
+ "Should return ID when no description."
+ (let ((result (test-link-description "[[id:abc123]]")))
+ (should (string= result "id:abc123"))))
+
+;;; Normal Cases - No Link
+
+(ert-deftest test-plain-text ()
+ "Should return plain text unchanged."
+ (let ((result (test-link-description "Plain text without link")))
+ (should (string= result "Plain text without link"))))
+
+(ert-deftest test-text-with-brackets-but-not-link ()
+ "Should return text with single brackets unchanged."
+ (let ((result (test-link-description "Text [with] brackets")))
+ (should (string= result "Text [with] brackets"))))
+
+(ert-deftest test-text-with-partial-link-syntax ()
+ "Should return text with partial link syntax unchanged."
+ (let ((result (test-link-description "[[incomplete link")))
+ (should (string= result "[[incomplete link"))))
+
+;;; Boundary Cases - Multiple Links
+
+(ert-deftest test-multiple-links-extracts-first ()
+ "Should extract description from first link only."
+ (let ((result (test-link-description "[[url1][First]] and [[url2][Second]]")))
+ (should (string= result "First"))))
+
+(ert-deftest test-multiple-links-first-has-no-description ()
+ "Should extract URL from first link when it has no description."
+ (let ((result (test-link-description "[[url1]] and [[url2][Second]]")))
+ (should (string= result "url1"))))
+
+;;; Boundary Cases - Empty and Edge Cases
+
+(ert-deftest test-empty-string ()
+ "Should return empty string unchanged."
+ (let ((result (test-link-description "")))
+ (should (string= result ""))))
+
+(ert-deftest test-link-with-empty-description ()
+ "Should return text unchanged when description brackets are empty."
+ (let ((result (test-link-description "[[https://example.com][]]")))
+ ;; Regex requires at least one char in description, so no match
+ (should (string= result "[[https://example.com][]]"))))
+
+(ert-deftest test-link-with-empty-url ()
+ "Should return text unchanged when link is completely empty."
+ (let ((result (test-link-description "[[]]")))
+ ;; Regex requires at least one char in URL, so no match, returns unchanged
+ (should (string= result "[[]]"))))
+
+(ert-deftest test-link-with-empty-url-and-description ()
+ "Should handle completely empty link."
+ (let ((result (test-link-description "[][]")))
+ (should (string= result "[][]"))))
+
+;;; Edge Cases - Special Link Types
+
+(ert-deftest test-internal-link ()
+ "Should extract description from internal link."
+ (let ((result (test-link-description "[[*Heading][My Heading]]")))
+ (should (string= result "My Heading"))))
+
+(ert-deftest test-internal-link-without-description ()
+ "Should return heading target from internal link without description."
+ (let ((result (test-link-description "[[*Heading]]")))
+ (should (string= result "*Heading"))))
+
+(ert-deftest test-custom-id-link ()
+ "Should handle custom ID links."
+ (let ((result (test-link-description "[[#custom-id][Custom Section]]")))
+ (should (string= result "Custom Section"))))
+
+;;; Edge Cases - Link with Surrounding Text
+
+(ert-deftest test-link-with-prefix-text ()
+ "Should extract description from link with prefix text."
+ (let ((result (test-link-description "See [[url][documentation]] for details")))
+ (should (string= result "documentation"))))
+
+(ert-deftest test-link-at-start ()
+ "Should extract description from link at start of text."
+ (let ((result (test-link-description "[[url][Link]] at beginning")))
+ (should (string= result "Link"))))
+
+(ert-deftest test-link-at-end ()
+ "Should extract description from link at end of text."
+ (let ((result (test-link-description "Text with [[url][link]]")))
+ (should (string= result "link"))))
+
+;;; Edge Cases - Special Characters in URL
+
+(ert-deftest test-link-with-query-params ()
+ "Should handle URL with query parameters."
+ (let ((result (test-link-description "[[https://example.com?q=test&foo=bar][Search]]")))
+ (should (string= result "Search"))))
+
+(ert-deftest test-link-with-anchor ()
+ "Should handle URL with anchor."
+ (let ((result (test-link-description "[[https://example.com#section][Section]]")))
+ (should (string= result "Section"))))
+
+(ert-deftest test-link-with-spaces-in-description ()
+ "Should preserve spaces in description."
+ (let ((result (test-link-description "[[url][Multiple Spaces]]")))
+ (should (string= result "Multiple Spaces"))))
+
+(provide 'test-org-roam-config-link-description)
+;;; test-org-roam-config-link-description.el ends here
diff --git a/tests/test-org-roam-config-slug.el b/tests/test-org-roam-config-slug.el
new file mode 100644
index 00000000..eb3149dd
--- /dev/null
+++ b/tests/test-org-roam-config-slug.el
@@ -0,0 +1,223 @@
+;;; test-org-roam-config-slug.el --- Tests for cj/--generate-roam-slug -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--generate-roam-slug function from org-roam-config.el
+;;
+;; This function converts a title to a filename-safe slug by:
+;; 1. Converting to lowercase
+;; 2. Replacing non-alphanumeric characters with hyphens
+;; 3. Removing leading and trailing hyphens
+;;
+;; Examples:
+;; Input: "My Project Name"
+;; Output: "my-project-name"
+;;
+;; Input: "Hello, World!"
+;; Output: "hello-world"
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Now load the actual production module
+(require 'org-roam-config)
+
+;;; Test Helpers
+
+(defun test-slug (title)
+ "Test cj/--generate-roam-slug on TITLE.
+Returns the slugified string."
+ (cj/--generate-roam-slug title))
+
+;;; Normal Cases - Simple Titles
+
+(ert-deftest test-slug-simple-word ()
+ "Should return lowercase simple word."
+ (let ((result (test-slug "Hello")))
+ (should (string= result "hello"))))
+
+(ert-deftest test-slug-multiple-words ()
+ "Should replace spaces with hyphens."
+ (let ((result (test-slug "My Project Name")))
+ (should (string= result "my-project-name"))))
+
+(ert-deftest test-slug-already-lowercase ()
+ "Should handle already lowercase text."
+ (let ((result (test-slug "simple")))
+ (should (string= result "simple"))))
+
+(ert-deftest test-slug-mixed-case ()
+ "Should convert mixed case to lowercase."
+ (let ((result (test-slug "MixedCaseTitle")))
+ (should (string= result "mixedcasetitle"))))
+
+;;; Normal Cases - Punctuation
+
+(ert-deftest test-slug-with-comma ()
+ "Should remove commas."
+ (let ((result (test-slug "Hello, World")))
+ (should (string= result "hello-world"))))
+
+(ert-deftest test-slug-with-period ()
+ "Should remove periods."
+ (let ((result (test-slug "Version 2.0")))
+ (should (string= result "version-2-0"))))
+
+(ert-deftest test-slug-with-exclamation ()
+ "Should remove exclamation marks."
+ (let ((result (test-slug "Hello World!")))
+ (should (string= result "hello-world"))))
+
+(ert-deftest test-slug-with-question ()
+ "Should remove question marks."
+ (let ((result (test-slug "What Is This?")))
+ (should (string= result "what-is-this"))))
+
+(ert-deftest test-slug-with-colon ()
+ "Should remove colons."
+ (let ((result (test-slug "Note: Important")))
+ (should (string= result "note-important"))))
+
+(ert-deftest test-slug-with-parentheses ()
+ "Should remove parentheses."
+ (let ((result (test-slug "Item (copy)")))
+ (should (string= result "item-copy"))))
+
+;;; Normal Cases - Numbers
+
+(ert-deftest test-slug-with-numbers ()
+ "Should preserve numbers."
+ (let ((result (test-slug "Chapter 42")))
+ (should (string= result "chapter-42"))))
+
+(ert-deftest test-slug-only-numbers ()
+ "Should handle titles with only numbers."
+ (let ((result (test-slug "123")))
+ (should (string= result "123"))))
+
+(ert-deftest test-slug-mixed-alphanumeric ()
+ "Should preserve alphanumeric characters."
+ (let ((result (test-slug "Test123ABC")))
+ (should (string= result "test123abc"))))
+
+;;; Boundary Cases - Multiple Consecutive Special Chars
+
+(ert-deftest test-slug-multiple-spaces ()
+ "Should collapse multiple spaces into single hyphen."
+ (let ((result (test-slug "Hello World")))
+ (should (string= result "hello-world"))))
+
+(ert-deftest test-slug-mixed-punctuation ()
+ "Should collapse mixed punctuation into single hyphen."
+ (let ((result (test-slug "Hello, ... World!")))
+ (should (string= result "hello-world"))))
+
+(ert-deftest test-slug-consecutive-hyphens ()
+ "Should collapse consecutive hyphens."
+ (let ((result (test-slug "Hello---World")))
+ (should (string= result "hello-world"))))
+
+;;; Boundary Cases - Leading/Trailing Special Chars
+
+(ert-deftest test-slug-leading-space ()
+ "Should remove leading hyphen from leading space."
+ (let ((result (test-slug " Hello")))
+ (should (string= result "hello"))))
+
+(ert-deftest test-slug-trailing-space ()
+ "Should remove trailing hyphen from trailing space."
+ (let ((result (test-slug "Hello ")))
+ (should (string= result "hello"))))
+
+(ert-deftest test-slug-leading-punctuation ()
+ "Should remove leading hyphen from leading punctuation."
+ (let ((result (test-slug "...Hello")))
+ (should (string= result "hello"))))
+
+(ert-deftest test-slug-trailing-punctuation ()
+ "Should remove trailing hyphen from trailing punctuation."
+ (let ((result (test-slug "Hello!!!")))
+ (should (string= result "hello"))))
+
+(ert-deftest test-slug-leading-and-trailing ()
+ "Should remove both leading and trailing hyphens."
+ (let ((result (test-slug " Hello World ")))
+ (should (string= result "hello-world"))))
+
+;;; Boundary Cases - Empty and Short
+
+(ert-deftest test-slug-empty-string ()
+ "Should return empty string for empty input."
+ (let ((result (test-slug "")))
+ (should (string= result ""))))
+
+(ert-deftest test-slug-only-punctuation ()
+ "Should return empty string for only punctuation."
+ (let ((result (test-slug "!!!")))
+ (should (string= result ""))))
+
+(ert-deftest test-slug-only-spaces ()
+ "Should return empty string for only spaces."
+ (let ((result (test-slug " ")))
+ (should (string= result ""))))
+
+(ert-deftest test-slug-single-char ()
+ "Should handle single character."
+ (let ((result (test-slug "A")))
+ (should (string= result "a"))))
+
+;;; Edge Cases - Special Characters
+
+(ert-deftest test-slug-with-underscore ()
+ "Should replace underscores with hyphens."
+ (let ((result (test-slug "my_variable_name")))
+ (should (string= result "my-variable-name"))))
+
+(ert-deftest test-slug-with-slash ()
+ "Should remove slashes."
+ (let ((result (test-slug "path/to/file")))
+ (should (string= result "path-to-file"))))
+
+(ert-deftest test-slug-with-at-sign ()
+ "Should remove at signs."
+ (let ((result (test-slug "user@example")))
+ (should (string= result "user-example"))))
+
+(ert-deftest test-slug-with-hash ()
+ "Should remove hash symbols."
+ (let ((result (test-slug "#hashtag")))
+ (should (string= result "hashtag"))))
+
+(ert-deftest test-slug-with-dollar ()
+ "Should remove dollar signs."
+ (let ((result (test-slug "$price")))
+ (should (string= result "price"))))
+
+;;; Edge Cases - Unicode (if supported)
+
+(ert-deftest test-slug-with-unicode ()
+ "Should remove unicode characters."
+ (let ((result (test-slug "Café")))
+ (should (string= result "caf"))))
+
+(ert-deftest test-slug-with-emoji ()
+ "Should remove emoji."
+ (let ((result (test-slug "Hello 😀 World")))
+ (should (string= result "hello-world"))))
+
+;;; Edge Cases - Long Titles
+
+(ert-deftest test-slug-very-long-title ()
+ "Should handle very long titles."
+ (let* ((long-title (mapconcat #'identity (make-list 20 "word") " "))
+ (result (test-slug long-title)))
+ (should (string-prefix-p "word-" result))
+ (should (string-suffix-p "-word" result))
+ (should (not (string-match-p " " result)))))
+
+(provide 'test-org-roam-config-slug)
+;;; test-org-roam-config-slug.el ends here
diff --git a/tests/test-org-webclipper-process.el b/tests/test-org-webclipper-process.el
new file mode 100644
index 00000000..9a25ef5c
--- /dev/null
+++ b/tests/test-org-webclipper-process.el
@@ -0,0 +1,210 @@
+;;; test-org-webclipper-process.el --- Tests for cj/--process-webclip-content -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--process-webclip-content function from org-webclipper.el
+;;
+;; This function processes webclipped org-mode content by:
+;; 1. Removing the first top-level heading
+;; 2. Removing any initial blank lines
+;; 3. Demoting all remaining headings by one level
+;;
+;; Examples:
+;; Input: "* Title\nContent\n** Sub\n"
+;; Output: "Content\n*** Sub\n"
+;;
+;; Input: "* Title\n\n\n** Sub\n"
+;; Output: "*** Sub\n"
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Now load the actual production module
+(require 'org-webclipper)
+
+;;; Test Helpers
+
+(defun test-process-webclip (content)
+ "Test cj/--process-webclip-content on CONTENT.
+Returns the processed content."
+ (cj/--process-webclip-content content))
+
+;;; Normal Cases - Single Heading Removal
+
+(ert-deftest test-process-removes-first-heading ()
+ "Should remove the first top-level heading."
+ (let ((result (test-process-webclip "* Title\nContent\n")))
+ (should (string= result "Content\n"))))
+
+(ert-deftest test-process-removes-heading-with-text ()
+ "Should remove first heading preserving body text."
+ (let ((result (test-process-webclip "* Page Title\nParagraph text\n")))
+ (should (string= result "Paragraph text\n"))))
+
+(ert-deftest test-process-removes-heading-with-tags ()
+ "Should remove first heading even with tags."
+ (let ((result (test-process-webclip "* Title :tag1:tag2:\nContent\n")))
+ (should (string= result "Content\n"))))
+
+(ert-deftest test-process-removes-heading-with-todo ()
+ "Should remove first heading even with TODO keyword."
+ (let ((result (test-process-webclip "* TODO Task\nContent\n")))
+ (should (string= result "Content\n"))))
+
+;;; Normal Cases - Blank Line Removal
+
+(ert-deftest test-process-removes-single-blank-line ()
+ "Should remove single blank line after heading removal."
+ (let ((result (test-process-webclip "* Title\n\nContent\n")))
+ (should (string= result "Content\n"))))
+
+(ert-deftest test-process-removes-multiple-blank-lines ()
+ "Should remove multiple blank lines after heading removal."
+ (let ((result (test-process-webclip "* Title\n\n\n\nContent\n")))
+ (should (string= result "Content\n"))))
+
+(ert-deftest test-process-removes-blank-lines-with-spaces ()
+ "Should remove blank lines that contain only spaces."
+ (let ((result (test-process-webclip "* Title\n \n\t\nContent\n")))
+ (should (string= result "Content\n"))))
+
+(ert-deftest test-process-preserves-blank-lines-in-content ()
+ "Should preserve blank lines within the content."
+ (let ((result (test-process-webclip "* Title\nPara 1\n\nPara 2\n")))
+ (should (string= result "Para 1\n\nPara 2\n"))))
+
+;;; Normal Cases - Heading Demotion
+
+(ert-deftest test-process-demotes-second-level ()
+ "Should demote level 2 heading to level 3."
+ (let ((result (test-process-webclip "* Title\n** Section\n")))
+ (should (string= result "*** Section\n"))))
+
+(ert-deftest test-process-demotes-third-level ()
+ "Should demote level 3 heading to level 4."
+ (let ((result (test-process-webclip "* Title\n*** Subsection\n")))
+ (should (string= result "**** Subsection\n"))))
+
+(ert-deftest test-process-demotes-multiple-headings ()
+ "Should demote all headings in the content."
+ (let ((result (test-process-webclip "* Title\n** Section 1\n** Section 2\n")))
+ (should (string= result "*** Section 1\n*** Section 2\n"))))
+
+(ert-deftest test-process-demotes-nested-hierarchy ()
+ "Should demote nested heading structure."
+ (let ((result (test-process-webclip "* Title\n** Section\n*** Subsection\n")))
+ (should (string= result "*** Section\n**** Subsection\n"))))
+
+;;; Normal Cases - Combined Processing
+
+(ert-deftest test-process-full-workflow ()
+ "Should remove heading, blank lines, and demote remaining headings."
+ (let ((result (test-process-webclip "* Article Title\n\n** Introduction\nText\n** Conclusion\n")))
+ (should (string= result "*** Introduction\nText\n*** Conclusion\n"))))
+
+(ert-deftest test-process-with-properties ()
+ "Should preserve properties in demoted headings."
+ (let ((result (test-process-webclip "* Title\n** Heading\n:PROPERTIES:\n:ID: 123\n:END:\n")))
+ (should (string= result "*** Heading\n:PROPERTIES:\n:ID: 123\n:END:\n"))))
+
+(ert-deftest test-process-with-mixed-content ()
+ "Should handle mixed text and headings."
+ (let ((result (test-process-webclip "* Title\nIntro text\n** Section\nBody text\n")))
+ (should (string= result "Intro text\n*** Section\nBody text\n"))))
+
+;;; Edge Cases - Empty and Minimal Content
+
+(ert-deftest test-process-empty-string ()
+ "Should return empty string for empty input."
+ (let ((result (test-process-webclip "")))
+ (should (string= result ""))))
+
+(ert-deftest test-process-only-heading ()
+ "Should return empty string when only first heading present."
+ (let ((result (test-process-webclip "* Title\n")))
+ (should (string= result ""))))
+
+(ert-deftest test-process-only-blank-lines ()
+ "Should return empty string for only blank lines after heading."
+ (let ((result (test-process-webclip "* Title\n\n\n")))
+ (should (string= result ""))))
+
+(ert-deftest test-process-no-heading ()
+ "Should handle content without any heading."
+ (let ((result (test-process-webclip "Just plain text\n")))
+ (should (string= result "Just plain text\n"))))
+
+(ert-deftest test-process-heading-no-newline ()
+ "Should demote heading without trailing newline (doesn't match removal pattern)."
+ (let ((result (test-process-webclip "* Title")))
+ (should (string= result "** Title"))))
+
+;;; Edge Cases - Heading Variations
+
+(ert-deftest test-process-heading-without-space ()
+ "Should not match heading without space after stars."
+ (let ((result (test-process-webclip "*Title\nContent\n")))
+ (should (string= result "*Title\nContent\n"))))
+
+(ert-deftest test-process-multiple-top-level-headings ()
+ "Should only remove first top-level heading."
+ (let ((result (test-process-webclip "* Title 1\n* Title 2\n")))
+ (should (string= result "** Title 2\n"))))
+
+(ert-deftest test-process-heading-with-priority ()
+ "Should remove heading with priority marker."
+ (let ((result (test-process-webclip "* [#A] Important\nContent\n")))
+ (should (string= result "Content\n"))))
+
+(ert-deftest test-process-heading-with-links ()
+ "Should remove heading containing links."
+ (let ((result (test-process-webclip "* [[url][Link Title]]\nContent\n")))
+ (should (string= result "Content\n"))))
+
+;;; Edge Cases - Special Content
+
+(ert-deftest test-process-preserves-lists ()
+ "Should preserve list formatting."
+ (let ((result (test-process-webclip "* Title\n- Item 1\n- Item 2\n")))
+ (should (string= result "- Item 1\n- Item 2\n"))))
+
+(ert-deftest test-process-preserves-code-blocks ()
+ "Should preserve code block content."
+ (let ((result (test-process-webclip "* Title\n#+BEGIN_SRC python\nprint('hi')\n#+END_SRC\n")))
+ (should (string= result "#+BEGIN_SRC python\nprint('hi')\n#+END_SRC\n"))))
+
+(ert-deftest test-process-preserves-tables ()
+ "Should preserve org table content."
+ (let ((result (test-process-webclip "* Title\n| A | B |\n| 1 | 2 |\n")))
+ (should (string= result "| A | B |\n| 1 | 2 |\n"))))
+
+;;; Edge Cases - Deep Nesting
+
+(ert-deftest test-process-very-deep-headings ()
+ "Should demote very deep heading structures."
+ (let ((result (test-process-webclip "* Title\n****** Level 6\n")))
+ (should (string= result "******* Level 6\n"))))
+
+(ert-deftest test-process-complex-document ()
+ "Should handle complex document structure."
+ (let ((result (test-process-webclip "* Main Title\n\n** Section 1\nText 1\n*** Subsection 1.1\nText 2\n** Section 2\nText 3\n")))
+ (should (string= result "*** Section 1\nText 1\n**** Subsection 1.1\nText 2\n*** Section 2\nText 3\n"))))
+
+;;; Integration Tests
+
+(ert-deftest test-process-realistic-webpage ()
+ "Should process realistic webclipped content."
+ (let ((result (test-process-webclip "* How to Program in Emacs Lisp\n\n** Introduction\nEmacs Lisp is powerful.\n\n** Getting Started\nFirst, open Emacs.\n\n*** Installation\nDownload from gnu.org\n")))
+ (should (string= result "*** Introduction\nEmacs Lisp is powerful.\n\n*** Getting Started\nFirst, open Emacs.\n\n**** Installation\nDownload from gnu.org\n"))))
+
+(ert-deftest test-process-article-with-metadata ()
+ "Should handle article with org metadata."
+ (let ((result (test-process-webclip "* Article Title :article:web:\n#+DATE: 2024-01-01\n\n** Content\nBody text\n")))
+ (should (string= result "#+DATE: 2024-01-01\n\n*** Content\nBody text\n"))))
+
+(provide 'test-org-webclipper-process)
+;;; test-org-webclipper-process.el ends here
diff --git a/tests/test-test-runner.el b/tests/test-test-runner.el
new file mode 100644
index 00000000..0edc0d65
--- /dev/null
+++ b/tests/test-test-runner.el
@@ -0,0 +1,359 @@
+;;; test-test-runner.el --- Tests for test-runner.el -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for test-runner.el - ERT test runner with focus/unfocus workflow.
+;;
+;; Testing approach:
+;; - Tests focus on internal `cj/test--do-*` functions (pure business logic)
+;; - File system operations use temp directories
+;; - Tests are isolated with setup/teardown
+;; - Tests verify return values, not user messages
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Load the module (ignore keymap error in batch mode)
+(condition-case nil
+ (require 'test-runner)
+ (error nil))
+
+;;; Test Utilities
+
+(defvar test-testrunner--temp-dir nil
+ "Temporary directory for test files during tests.")
+
+(defvar test-testrunner--original-focused-files nil
+ "Backup of focused files list before test.")
+
+(defun test-testrunner-setup ()
+ "Setup test environment before each test."
+ ;; Backup current state
+ (setq test-testrunner--original-focused-files cj/test-focused-files)
+ ;; Reset to clean state
+ (setq cj/test-focused-files '())
+ ;; Create temp directory for file tests
+ (setq test-testrunner--temp-dir (make-temp-file "test-runner-test" t)))
+
+(defun test-testrunner-teardown ()
+ "Clean up test environment after each test."
+ ;; Restore state
+ (setq cj/test-focused-files test-testrunner--original-focused-files)
+ ;; Clean up temp directory
+ (when (and test-testrunner--temp-dir
+ (file-directory-p test-testrunner--temp-dir))
+ (delete-directory test-testrunner--temp-dir t))
+ (setq test-testrunner--temp-dir nil))
+
+(defun test-testrunner-create-test-file (filename content)
+ "Create test file FILENAME with CONTENT in temp directory."
+ (let ((filepath (expand-file-name filename test-testrunner--temp-dir)))
+ (with-temp-file filepath
+ (insert content))
+ filepath))
+
+;;; Normal Cases - Load Files
+
+(ert-deftest test-testrunner-load-files-success ()
+ "Should successfully load test files."
+ (test-testrunner-setup)
+ (let* ((file1 (test-testrunner-create-test-file "test-simple.el"
+ "(defun test-func () t)"))
+ (file2 (test-testrunner-create-test-file "test-other.el"
+ "(defun other-func () nil)"))
+ (result (cj/test--do-load-files test-testrunner--temp-dir
+ (list file1 file2))))
+ (should (eq (car result) 'success))
+ (should (= (cdr result) 2)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-load-files-with-errors ()
+ "Should handle errors during file loading."
+ (test-testrunner-setup)
+ (let* ((good-file (test-testrunner-create-test-file "test-good.el"
+ "(defun good () t)"))
+ (bad-file (test-testrunner-create-test-file "test-bad.el"
+ "(defun bad ( "))
+ (result (cj/test--do-load-files test-testrunner--temp-dir
+ (list good-file bad-file))))
+ (should (eq (car result) 'error))
+ (should (= (nth 1 result) 1)) ; loaded-count
+ (should (= (length (nth 2 result)) 1))) ; errors list
+ (test-testrunner-teardown))
+
+;;; Normal Cases - Focus Add
+
+(ert-deftest test-testrunner-focus-add-success ()
+ "Should successfully add file to focus."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-add "test-foo.el"
+ '("test-foo.el" "test-bar.el")
+ '())))
+ (should (eq result 'success)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-focus-add-already-focused ()
+ "Should detect already focused file."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-add "test-foo.el"
+ '("test-foo.el" "test-bar.el")
+ '("test-foo.el"))))
+ (should (eq result 'already-focused)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-focus-add-not-available ()
+ "Should detect file not in available list."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-add "test-missing.el"
+ '("test-foo.el" "test-bar.el")
+ '())))
+ (should (eq result 'not-available)))
+ (test-testrunner-teardown))
+
+;;; Normal Cases - Focus Add File
+
+(ert-deftest test-testrunner-focus-add-file-success ()
+ "Should successfully validate and add file to focus."
+ (test-testrunner-setup)
+ (let* ((filepath (expand-file-name "test-foo.el" test-testrunner--temp-dir))
+ (result (cj/test--do-focus-add-file filepath test-testrunner--temp-dir '())))
+ (should (eq (car result) 'success))
+ (should (string= (cdr result) "test-foo.el")))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-focus-add-file-no-file ()
+ "Should detect nil filepath."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-add-file nil test-testrunner--temp-dir '())))
+ (should (eq (car result) 'no-file)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-focus-add-file-not-in-testdir ()
+ "Should detect file outside test directory."
+ (test-testrunner-setup)
+ (let* ((filepath "/tmp/outside-test.el")
+ (result (cj/test--do-focus-add-file filepath test-testrunner--temp-dir '())))
+ (should (eq (car result) 'not-in-testdir)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-focus-add-file-already-focused ()
+ "Should detect already focused file."
+ (test-testrunner-setup)
+ (let* ((filepath (expand-file-name "test-foo.el" test-testrunner--temp-dir))
+ (result (cj/test--do-focus-add-file filepath
+ test-testrunner--temp-dir
+ '("test-foo.el"))))
+ (should (eq (car result) 'already-focused))
+ (should (string= (cdr result) "test-foo.el")))
+ (test-testrunner-teardown))
+
+;;; Normal Cases - Focus Remove
+
+(ert-deftest test-testrunner-focus-remove-success ()
+ "Should successfully remove file from focus."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-remove "test-foo.el" '("test-foo.el" "test-bar.el"))))
+ (should (eq result 'success)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-focus-remove-empty-list ()
+ "Should detect empty focused list."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-remove "test-foo.el" '())))
+ (should (eq result 'empty-list)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-focus-remove-not-found ()
+ "Should detect file not in focused list."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-remove "test-missing.el" '("test-foo.el"))))
+ (should (eq result 'not-found)))
+ (test-testrunner-teardown))
+
+;;; Normal Cases - Get Focused Tests
+
+(ert-deftest test-testrunner-get-focused-tests-success ()
+ "Should extract test names from focused files."
+ (test-testrunner-setup)
+ (let* ((file1 (test-testrunner-create-test-file "test-first.el"
+ "(ert-deftest test-alpha-one () (should t))\n(ert-deftest test-alpha-two () (should t))"))
+ (result (cj/test--do-get-focused-tests '("test-first.el") test-testrunner--temp-dir)))
+ (should (eq (car result) 'success))
+ (should (= (length (nth 1 result)) 2)) ; 2 test names
+ (should (= (nth 2 result) 1))) ; 1 file loaded
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-get-focused-tests-empty-list ()
+ "Should detect empty focused files list."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-get-focused-tests '() test-testrunner--temp-dir)))
+ (should (eq (car result) 'empty-list)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-get-focused-tests-no-tests ()
+ "Should detect when no tests found in files."
+ (test-testrunner-setup)
+ (test-testrunner-create-test-file "test-empty.el" "(defun not-a-test () t)")
+ (let ((result (cj/test--do-get-focused-tests '("test-empty.el") test-testrunner--temp-dir)))
+ (should (eq (car result) 'no-tests)))
+ (test-testrunner-teardown))
+
+;;; Normal Cases - Extract Test Names
+
+(ert-deftest test-testrunner-extract-test-names-simple ()
+ "Should extract test names from file."
+ (test-testrunner-setup)
+ (let* ((file (test-testrunner-create-test-file "test-simple.el"
+ "(ert-deftest test-foo () (should t))\n(ert-deftest test-bar () (should nil))"))
+ (names (cj/test--extract-test-names file)))
+ (should (= (length names) 2))
+ (should (member "test-foo" names))
+ (should (member "test-bar" names)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-extract-test-names-with-whitespace ()
+ "Should extract test names with various whitespace."
+ (test-testrunner-setup)
+ (let* ((file (test-testrunner-create-test-file "test-whitespace.el"
+ "(ert-deftest test-spaces () (should t))\n (ert-deftest test-indent () t)"))
+ (names (cj/test--extract-test-names file)))
+ (should (= (length names) 2))
+ (should (member "test-spaces" names))
+ (should (member "test-indent" names)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-extract-test-names-no-tests ()
+ "Should return empty list when no tests in file."
+ (test-testrunner-setup)
+ (let* ((file (test-testrunner-create-test-file "test-none.el"
+ "(defun not-a-test () t)"))
+ (names (cj/test--extract-test-names file)))
+ (should (null names)))
+ (test-testrunner-teardown))
+
+;;; Normal Cases - Extract Test at Position
+
+(ert-deftest test-testrunner-extract-test-at-pos-found ()
+ "Should extract test name at point."
+ (test-testrunner-setup)
+ (with-temp-buffer
+ (insert "(ert-deftest test-sample ()\n (should t))")
+ (goto-char (point-min))
+ (let ((name (cj/test--extract-test-at-pos)))
+ (should (eq name 'test-sample))))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-extract-test-at-pos-not-found ()
+ "Should return nil when not in a test."
+ (test-testrunner-setup)
+ (with-temp-buffer
+ (insert "(defun regular-function ()\n (message \"hi\"))")
+ (goto-char (point-min))
+ (let ((name (cj/test--extract-test-at-pos)))
+ (should (null name))))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-extract-test-at-pos-invalid-syntax ()
+ "Should return nil for invalid syntax."
+ (test-testrunner-setup)
+ (with-temp-buffer
+ (insert "(ert-deftest")
+ (goto-char (point-min))
+ (let ((name (cj/test--extract-test-at-pos)))
+ (should (null name))))
+ (test-testrunner-teardown))
+
+;;; Boundary Cases - Load Files
+
+(ert-deftest test-testrunner-load-files-empty-list ()
+ "Should handle empty file list."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-load-files test-testrunner--temp-dir '())))
+ (should (eq (car result) 'success))
+ (should (= (cdr result) 0)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-load-files-nonexistent ()
+ "Should handle nonexistent files."
+ (test-testrunner-setup)
+ (let* ((fake-file (expand-file-name "nonexistent.el" test-testrunner--temp-dir))
+ (result (cj/test--do-load-files test-testrunner--temp-dir (list fake-file))))
+ (should (eq (car result) 'error))
+ (should (= (nth 1 result) 0))) ; 0 files loaded
+ (test-testrunner-teardown))
+
+;;; Boundary Cases - Focus Add
+
+(ert-deftest test-testrunner-focus-add-single-available ()
+ "Should add when only one file available."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-add "test-only.el" '("test-only.el") '())))
+ (should (eq result 'success)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-focus-add-case-sensitive ()
+ "Should be case-sensitive for filenames."
+ (test-testrunner-setup)
+ (let ((result (cj/test--do-focus-add "Test-Foo.el"
+ '("test-foo.el")
+ '())))
+ (should (eq result 'not-available)))
+ (test-testrunner-teardown))
+
+;;; Boundary Cases - Get Focused Tests
+
+(ert-deftest test-testrunner-get-focused-tests-multiple-files ()
+ "Should collect tests from multiple files."
+ (test-testrunner-setup)
+ (test-testrunner-create-test-file "test-first.el"
+ "(ert-deftest test-beta-one () t)")
+ (test-testrunner-create-test-file "test-second.el"
+ "(ert-deftest test-beta-two () t)")
+ (let ((result (cj/test--do-get-focused-tests '("test-first.el" "test-second.el")
+ test-testrunner--temp-dir)))
+ (should (eq (car result) 'success))
+ (should (= (length (nth 1 result)) 2)) ; 2 tests total
+ (should (= (nth 2 result) 2))) ; 2 files loaded
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-get-focused-tests-skip-nonexistent ()
+ "Should skip nonexistent files."
+ (test-testrunner-setup)
+ (test-testrunner-create-test-file "test-exists.el"
+ "(ert-deftest test-gamma-one () t)")
+ (let ((result (cj/test--do-get-focused-tests '("test-exists.el" "test-missing.el")
+ test-testrunner--temp-dir)))
+ (should (eq (car result) 'success))
+ (should (= (length (nth 1 result)) 1)) ; 1 test found
+ (should (= (nth 2 result) 1))) ; 1 file loaded (missing skipped)
+ (test-testrunner-teardown))
+
+;;; Boundary Cases - Extract Test Names
+
+(ert-deftest test-testrunner-extract-test-names-hyphens-underscores ()
+ "Should handle test names with hyphens and underscores."
+ (test-testrunner-setup)
+ (let* ((file (test-testrunner-create-test-file "test-names.el"
+ "(ert-deftest test-with-hyphens () t)\n(ert-deftest test_with_underscores () t)"))
+ (names (cj/test--extract-test-names file)))
+ (should (= (length names) 2))
+ (should (member "test-with-hyphens" names))
+ (should (member "test_with_underscores" names)))
+ (test-testrunner-teardown))
+
+(ert-deftest test-testrunner-extract-test-names-ignore-comments ()
+ "Should not extract test names from comments."
+ (test-testrunner-setup)
+ (let* ((file (test-testrunner-create-test-file "test-comments.el"
+ ";; (ert-deftest test-commented () t)\n(ert-deftest test-real () t)"))
+ (names (cj/test--extract-test-names file)))
+ (should (= (length names) 1))
+ (should (member "test-real" names)))
+ (test-testrunner-teardown))
+
+(provide 'test-test-runner)
+;;; test-test-runner.el ends here
diff --git a/tests/test-theme-theme-persistence.el.disabled b/tests/test-theme-theme-persistence.el.disabled
deleted file mode 100644
index e0b2f9e3..00000000
--- a/tests/test-theme-theme-persistence.el.disabled
+++ /dev/null
@@ -1,135 +0,0 @@
-;;; test-theme-theme-persistence.el --- Tests theme persistence mechanism -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; Unit tests for the persistence of the chosen theme
-
-;;; Code:
-
-(add-to-list 'load-path (concat user-emacs-directory "modules"))
-(require 'ui-theme)
-
-;; ------------------------ Constants / Setup / Teardown -----------------------
-
-(defvar cj/original-theme-name nil)
-(defvar cj/original-newline-setting nil)
-
-(defun cj/test-setup ()
- "Required settings and save state before each test."
-
- ;; save the current theme for restoration
- (setq cj/original-theme-name (symbol-name (car custom-enabled-themes)))
- (setq cj/original-newline-setting mode-require-final-newline)
-
- ;; unload all themes before starting test
- (mapcar #'disable-theme custom-enabled-themes)
-
- ;; no EOF newlines
- (custom-set-variables
- '(require-final-newline nil))
- (setq mode-require-final-newline nil))
-
-(defun cj/test-teardown ()
- "Restore the state before each test."
- ;; restore newline setting
- (setq require-final-newline cj/original-newline-setting)
-
- ;; if there wasn't an original theme, remove all themes
- (if (string= cj/original-theme-name "nil")
- (mapcar #'disable-theme custom-enabled-themes)
- ;; otherwise, restore it
- (load-theme (intern cj/original-theme-name))))
-
-;; ----------------------------------- Tests -----------------------------------
-
-(ert-deftest test-write-file-contents ()
- "Normal Case: Uses function to write a string, reads it back, and compares."
- (cj/test-setup)
- (let ((teststring "testing123")
- (testfilename "test-write-file-contents.txt"))
- ;; call the function
- (should (equal (cj/write-file-contents teststring testfilename)
- 't))
- ;; Read the file and check it's contents
- (should (equal (with-temp-buffer(insert-file-contents testfilename)
- (buffer-string))
- teststring))
- ;; clean up test file
- (delete-file testfilename))
- (cj/test-teardown))
-
-(ert-deftest test-write-file-not-writable ()
- "Test writing to a non-writable file."
- (cl-flet ((file-writeable-p (file) nil))
- (let* ((non-writable-file (make-temp-file "test-non-writable"))
- (should (equal (cj/write-file-contents "cowabunga" non-writable-file) 'nil)))
- (delete-file non-writable-file))))
-
-(ert-deftest test-read-file-contents ()
- "Normal Case: Writes string to file and reads contents using function."
- (cj/test-setup)
- (let ((teststring "testing123")
- (testfilename "test-read-file-contents.txt"))
- ;; write the file
- (with-temp-buffer
- (insert teststring)
- (write-file testfilename))
- ;; call the function
- (should (equal (cj/read-file-contents testfilename)
- teststring))
- ;; clean up test file
- (delete-file testfilename))
- (cj/test-teardown))
-
-(ert-deftest test-read-file-nonexistent ()
- "Test reading from a non-existent file returns nil."
- (cj/test-setup)
- (let* ((filename (concat (number-to-string (random 99999999)) "nonexistent-file.txt"))
- (result (cj/read-file-contents filename)))
- (should (equal result nil)))
- (cj/test-teardown))
-
-(ert-deftest test-get-active-theme ()
- (cj/test-setup)
- "Normal Case: Sets theme, gets theme-name, and compares."
- (let ((expected "wombat"))
- (load-theme (intern expected))
- (should (string= (cj/get-active-theme-name) expected))
- (cj/test-teardown)))
-
-(ert-deftest test-get-active-theme ()
- (cj/test-setup)
- "Normal Case: Sets theme, gets theme-name, and compares."
- (let ((expected "nil"))
- (mapcar #'disable-theme custom-enabled-themes)
- (should (equal (cj/get-active-theme-name) expected))
- (cj/test-teardown)))
-
-(ert-deftest test-save-theme-to-file ()
- "Normal case: sets theme, saves it, reads from file, and compares."
- (cj/test-setup)
- (let ((expected "wombat"))
- (load-theme (intern expected))
- (cj/save-theme-to-file)
- (should (equal (cj/read-file-contents theme-file) expected))
- (cj/test-teardown)))
-
-(ert-deftest test-load-theme-from-file ()
- "Normal case: saves new theme to file, loads it from file, and compares."
- (cj/test-setup)
- (let ((expected "wombat")) ;; the ui theme that test-setup uses.
- (cj/write-file-contents expected theme-file)
- (cj/load-theme-from-file)
- (should (equal expected (cj/get-active-theme-name))))
- (cj/test-teardown))
-
-(ert-deftest test-load-nil-theme ()
- "Corner case: saves 'nil as theme name to file, loads it, and compares to not having a theme."
- (cj/test-setup)
- (let ((expected "nil")) ;; the ui theme that test-setup uses.
- (cj/write-file-contents expected theme-file)
- (cj/load-theme-from-file)
- (should (equal expected (cj/get-active-theme-name))))
- (cj/test-teardown))
-
-(provide 'test-theme-theme-persistence)
-;;; test-theme-theme-persistence.el ends here.
diff --git a/tests/test-title-case-region.el.disabled b/tests/test-title-case-region.el.disabled
deleted file mode 100644
index ffab0c24..00000000
--- a/tests/test-title-case-region.el.disabled
+++ /dev/null
@@ -1,44 +0,0 @@
-;;; test-title-case-region.el --- -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; Tests for the title-case region function in custom-functions.el
-
-;; Note on Title Case
-;; Title case is a capitalization convention where major words are
-;; capitalized,and most minor words are lowercase. Nouns,verbs (including
-;; linking verbs), adjectives, adverbs,pronouns,and all words of four letters or
-;; more are considered major words. Short (i.e., three letters or fewer)
-;; conjunctions, short prepositions,and all articles are considered minor
-;; words."
-
-;; positive case (single line, all lowercase, no skip words)
-;; positive case (six lines, mixed case, skip words)
-;; negative case (single line, all skip-words)
-;; negative case (a long empty string)
-
-
-;;; Code:
-
-(require 'ert)
-(add-to-list 'load-path (concat user-emacs-directory "modules"))
-(require 'custom-functions)
-
-(ert-deftest test-cj/fixup-whitespace-positive-first-line-only ()
- "Test a positive case with two lines.
-Both lines have whitespace at the beginning and the end. This tests that when
-this function is called on the first line, only that line is affected."
- (let ((testdata " Hello, world! \n Foo bar ")
- (expected "Hello, world!\n Foo bar ")
- (actual))
- (with-temp-buffer
- (insert testdata)
- (goto-char (point-min))
- (cj/fixup-whitespace-line-or-region)
- (setq actual (buffer-string))
- (should (string= actual expected)))))
-
-
-
-
-(provide 'test-title-case-region)
-;;; test-title-case-region.el ends here.
diff --git a/tests/test-undead-buffers.el b/tests/test-undead-buffers.el
index 606972be..38187525 100644
--- a/tests/test-undead-buffers.el
+++ b/tests/test-undead-buffers.el
@@ -1,7 +1,16 @@
;;; test-undead-buffers.el --- -*- coding: utf-8; lexical-binding: t; -*-
;;; Commentary:
-;;
+;; ERT tests for undead-buffers.el.
+;; Exercises kill vs bury decisions driven by cj/buffer-bury-alive-list
+;; and window-management helpers.
+;; Coverage:
+;; - cj/kill-buffer-or-bury-alive: kills non-listed buffers; buries listed; C-u adds to list
+;; - cj/kill-buffer-and-window: deletes selected window, then kill/bury buffer as appropriate
+;; - cj/kill-other-window: deletes the other window, then kill/bury that buffer
+;; - cj/kill-all-other-buffers-and-windows: keeps only current window/buffer
+;; Tests isolate state with temporary buffers/windows and restore cj/buffer-bury-alive-list.
+;; Note: bury-buffer does not delete windows; tests assert buffer liveness, not window removal.
;;; Code:
diff --git a/tests/testutil-general.el b/tests/testutil-general.el
index d3c08135..b7222d1a 100644
--- a/tests/testutil-general.el
+++ b/tests/testutil-general.el
@@ -81,31 +81,6 @@ Return the full created path."
(cj/create--directory-ensuring-parents path)
(cj/create--file-ensuring-parents path content executable))))
-
-;; (defun cj/create-file-with-content-ensuring-parents (filepath content &optional executable)
-;; "Create a file at FILEPATH with CONTENT, ensuring parent directories exist.
-;; FILEPATH will be relative to `cj/test-base-dir'.
-;; Signals an error if the file already exists.
-;; If EXECUTABLE is non-nil, set executable permission on the file.
-;; Errors if the resulting path is outside `cj/test-base-dir`."
-;; (let* ((base (file-name-as-directory cj/test-base-dir))
-;; (fullpath (if (file-name-absolute-p filepath)
-;; (expand-file-name filepath)
-;; (expand-file-name filepath base))))
-;; (unless (string-prefix-p base fullpath)
-;; (error "File path %s is outside base test directory %s" fullpath base))
-;; (let ((parent-dir (file-name-directory fullpath)))
-;; (when (file-exists-p fullpath)
-;; (error "File already exists: %s" fullpath))
-;; (unless (file-directory-p parent-dir)
-;; (make-directory parent-dir t))
-;; (with-temp-buffer
-;; (insert content)
-;; (write-file fullpath))
-;; (when executable
-;; (chmod fullpath #o755))
-;; fullpath)))
-
(defun cj/fix-permissions-recursively (dir)
"Recursively set read/write permissions for user under DIR.
Directories get user read, write, and execute permissions to allow recursive
diff --git a/todo.org b/todo.org
index 75b3de93..399bc1bd 100644
--- a/todo.org
+++ b/todo.org
@@ -1,32 +1,5 @@
* Emacs Config Open Work
** TODO [#A] Finish Testing New Org-webclipper
-** TODO [#B] investigate unassociated files
-- [ ] /home/cjennings/.emacs.d/assets/test/test-misspellings.org
-- [ ] /home/cjennings/.emacs.d/assets/test/test.org
-- [ ] /home/cjennings/.emacs.d/custom/c-boxes.el
-- [ ] /home/cjennings/.emacs.d/custom/edit-indirect.el
-- [ ] /home/cjennings/.emacs.d/custom/eplot.el
-- [ ] /home/cjennings/.emacs.d/custom/gptel-prompts.el
-- [ ] /home/cjennings/.emacs.d/custom/org-checklist.el
-- [ ] /home/cjennings/.emacs.d/custom/pdf-continuous-scroll-mode.el
-- [ ] /home/cjennings/.emacs.d/custom/pdf-continuous-scroll-mode-latest.el
-- [ ] /home/cjennings/.emacs.d/custom/profile-dotemacs.el
-- [ ] /home/cjennings/.emacs.d/custom/titlecase.el
-- [ ] /home/cjennings/.emacs.d/custom/utilities/vcf-conversion-helpers.el
-- [ ] /home/cjennings/.emacs.d/gptel-tools/update_text_file.el
-- [ ] /home/cjennings/.emacs.d/modules/ai-conversations.el
-- [ ] /home/cjennings/.emacs.d/modules/erc-config.el
-- [ ] /home/cjennings/.emacs.d/modules/jumper.el
-- [ ] /home/cjennings/.emacs.d/modules/ledger-config.el
-- [ ] /home/cjennings/.emacs.d/modules/lipsum-generator.el
-- [ ] /home/cjennings/.emacs.d/modules/lorem-generator.el
-- [ ] /home/cjennings/.emacs.d/modules/mu4e-org-contacts-integration.el
-- [ ] /home/cjennings/.emacs.d/modules/mu4e-org-contacts-setup.el
-- [ ] /home/cjennings/.emacs.d/modules/org-noter-config.el
-- [ ] /home/cjennings/.emacs.d/modules/prog-lsp.el
-- [ ] /home/cjennings/.emacs.d/modules/wip.el
-- [ ] /home/cjennings/.emacs.d/themes/dupre-theme.el
-- [ ] /home/cjennings/.emacs.d/todo.org
** TODO [#A] Document Goals, Specs, and Architecture Docs for this Config :maintenance:
*** Emacs Config V2MOM
Misc Requirements
@@ -107,6 +80,57 @@ https://github.com/hakimel/reveal.js
https://www.youtube.com/watch?v=psDpCpcIVYs&t=10s
**** Install Org Reveal
+** TODO [#B] Implement additional custom operations
+After analyzing all custom-* modules, the following line/paragraph operations are missing and would be useful additions to custom-line-paragraph.el:
+
+*** Most Useful Operations (High Priority)
+- [ ] Copy current line - Duplicate line to kill ring without cutting (like yy in Vim)
+- [ ] Sort lines - Sort lines alphabetically, numerically, or by custom key
+- [ ] Insert blank line above - Add empty line above current line without moving cursor
+- [ ] Insert blank line below - Add empty line below current line without moving cursor
+
+*** Additional Operations (Medium Priority)
+- [ ] Reverse lines - Reverse the order of lines in region or buffer
+- [ ] Shuffle lines - Randomly reorder lines in region or buffer
+- [ ] Uniquify lines - Remove duplicate lines while preserving order (different from existing remove-duplicates which uses regex)
+- [ ] Filter lines matching regex - Keep only lines matching a pattern (inverse of remove-lines-containing)
+- [ ] Number/enumerate lines - Add line numbers or enumeration to lines in region
+- [ ] Join with separator - Join lines with custom separator (e.g., comma, semicolon)
+
+*** Lower Priority Operations
+- [ ] Kill whole line - Delete entire line including newline (like dd in Vim)
+- [ ] Wrap/unwrap at column - Hard wrap at fill-column or unwrap wrapped lines
+- [ ] Indent/dedent region - Increase or decrease indentation for region
+
+Note: Some operations like fill-paragraph, transpose-lines, and basic indentation already exist in Emacs core. This list focuses on commonly-needed operations not yet implemented in custom modules.
+
+** TODO [#B] investigate unassociated files
+- [ ] /home/cjennings/.emacs.d/assets/test/test-misspellings.org
+- [ ] /home/cjennings/.emacs.d/assets/test/test.org
+- [ ] /home/cjennings/.emacs.d/custom/c-boxes.el
+- [ ] /home/cjennings/.emacs.d/custom/edit-indirect.el
+- [ ] /home/cjennings/.emacs.d/custom/eplot.el
+- [ ] /home/cjennings/.emacs.d/custom/gptel-prompts.el
+- [ ] /home/cjennings/.emacs.d/custom/org-checklist.el
+- [ ] /home/cjennings/.emacs.d/custom/pdf-continuous-scroll-mode.el
+- [ ] /home/cjennings/.emacs.d/custom/pdf-continuous-scroll-mode-latest.el
+- [ ] /home/cjennings/.emacs.d/custom/profile-dotemacs.el
+- [ ] /home/cjennings/.emacs.d/custom/titlecase.el
+- [ ] /home/cjennings/.emacs.d/custom/utilities/vcf-conversion-helpers.el
+- [ ] /home/cjennings/.emacs.d/gptel-tools/update_text_file.el
+- [ ] /home/cjennings/.emacs.d/modules/ai-conversations.el
+- [ ] /home/cjennings/.emacs.d/modules/erc-config.el
+- [ ] /home/cjennings/.emacs.d/modules/jumper.el
+- [ ] /home/cjennings/.emacs.d/modules/ledger-config.el
+- [ ] /home/cjennings/.emacs.d/modules/lipsum-generator.el
+- [ ] /home/cjennings/.emacs.d/modules/lorem-generator.el
+- [ ] /home/cjennings/.emacs.d/modules/mu4e-org-contacts-integration.el
+- [ ] /home/cjennings/.emacs.d/modules/mu4e-org-contacts-setup.el
+- [ ] /home/cjennings/.emacs.d/modules/org-noter-config.el
+- [ ] /home/cjennings/.emacs.d/modules/prog-lsp.el
+- [ ] /home/cjennings/.emacs.d/modules/wip.el
+- [ ] /home/cjennings/.emacs.d/themes/dupre-theme.el
+- [ ] /home/cjennings/.emacs.d/todo.org
** TODO [#B] Replace customization constructions in modules :bug:
Files using defcustom or other customization-related constructions:
- media-utils.el
@@ -769,6 +793,7 @@ use case:
- If I'm in a projectile/project.el project it overrides the destination to an resolved.org file
- if the resolved.org file doesn't exist, it creates it.
- If not in a projectile/project.el project, it falls back to the archive.org file.
+** TODO [#B] M-y and show-kill-ring don't load first launch
** WAITING [#C] Fix Org-msg Issue With Undo Outside :bug:
*** 2025-09-19 Fri @ 17:45:35 -0500 Submitted PR Waiting for Merge
https://github.com/jeremy-compostella/org-msg/pull/213
@@ -983,6 +1008,38 @@ Org-roam and its database sync run at startup. Load Org-roam only when Org is ac
** TODO [#C] Git Timemachine Litters Empty Buffers :bug:
Don't choose a revision and you'll see a blank buffer that needs to be killed
+** TODO [#C] capture windows should pop up from bottom and 1/3 at most
+** create unfill paragraph (paragraph to single line)
+#+BEGIN_QUOTE
+I like to have a keybind to do the opposite of fill-paragraph. It’s taken from Stefan Monnier.
+
+(defun unfill-paragraph (&optional region)
+ "Takes a multi-line paragraph and turns it into a single line"
+ (interactive (progn (barf-if-buffer-read-only) '(t)))
+ (let ((fill-column (point-max))
+ (emacs-lisp-docstring-fill-column t))
+ (fill-paragraph nil region)))
+(define-key global-map "\M-Q" 'unfill-paragraph)
+#+END_QUOTE
+[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]]
+Captured On: [2025-10-15 Wed 18:35]
+** install visible mark so you know where the mark is
+#+BEGIN_QUOTE
+Showing the location of the mark, probably helps with learning to use mark better.
+
+(use-package visible-mark
+ :init
+ (global-visible-mark-mode)
+ :custom
+ (visible-mark-faces '(visible-mark-face1 visible-mark-face2))
+ (visible-mark-forward-faces '(visible-mark-face1 visible-mark-face2))
+ (visible-mark-max 2))
+#+END_QUOTE
+[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]]
+Captured On: [2025-10-15 Wed 18:33]
+** BrainCurses Emacs Mastermind Game
+[[https://sourceforge.net/projects/braincurses/][BrainCurses download | SourceForge.net]]
+Captured On: [2025-09-24 Wed 00:38]
* Emacs Config Next Release: 0.9
** DOING [#A] Jumper Package :enhancement:
*** Specification
@@ -1086,6 +1143,134 @@ Depending on context, can I add the
- First few words of the line
Do we think the reordering behavior when deleting locations might confuse users? How to simplify?
What happens if a buffer is deleted that doesn't have a file associated with it? If we're using registers underneath, how do registers handle this?
+** Edit Indirect (Custom Code) Bugs and Improvements
+*** Bugs (by Severity)
+
+*** High Severity
+
+*Race Condition in =edit-indirect--commit=*
+The commit function modifies the parent buffer while iterating through hooks that may also modify the buffer. The use of markers helps, but there's still a potential race condition if hooks modify the region being replaced. The =save-match-data= approach with markers could fail if the hooks significantly alter the buffer structure between marker creation and replacement.
+
+*Potential Data Loss in =edit-indirect--clean-up=*
+The function calls =delete-overlay= before setting =edit-indirect--overlay= to nil. If =delete-overlay= signals an error (e.g., if the overlay was already deleted), the overlay reference won't be cleared, potentially causing infinite loops in =edit-indirect--abort-on-kill-buffer=.
+
+*** Medium Severity
+
+*Missing Buffer Validation in =edit-indirect--commit=*
+The function doesn't check if the parent buffer still exists before attempting to modify it. If the parent buffer was killed while the indirect buffer was being edited, this will cause an error.
+
+*Hook Execution Order Issue*
+The =edit-indirect--rebind-save-hooks= function is added to =after-change-major-mode-hook= globally but never removed. This means it will be called for every buffer that changes major mode, not just edit-indirect buffers, causing unnecessary overhead.
+
+*** Low Severity
+
+*Inefficient Overlay Search in =edit-indirect--search-for-edit-indirect=*
+The function uses =overlays-in= which returns all overlays, then filters them. For buffers with many overlays, this is inefficient.
+
+*Missing Error Handling in =edit-indirect--display-buffer=*
+The function doesn't handle cases where =display-buffer= returns nil (which can happen if all windows are dedicated or display is inhibited).
+
+*** Recommended Improvements
+
+*** Easy Implementation
+
+*Add Buffer Existence Check*
+Add a check in =edit-indirect--commit= to ensure the parent buffer still exists:
+#+begin_src emacs-lisp
+(unless (buffer-live-p (overlay-buffer edit-indirect--overlay))
+ (error "Parent buffer no longer exists"))
+#+end_src
+
+*Improve Hook Management*
+Remove the global hook when not needed:
+#+begin_src emacs-lisp
+(defun edit-indirect--rebind-save-hooks ()
+ (when (edit-indirect-buffer-indirect-p)
+ (setq buffer-offer-save t)
+ (add-hook 'write-contents-functions #'edit-indirect--commit-on-save nil t)
+ ;; Remove global hook after use
+ (remove-hook 'after-change-major-mode-hook #'edit-indirect--rebind-save-hooks)))
+#+end_src
+
+*Add Safety to Clean-up*
+Wrap cleanup operations in condition-case:
+#+begin_src emacs-lisp
+(defun edit-indirect--clean-up ()
+ (condition-case nil
+ (delete-overlay edit-indirect--overlay)
+ (error nil))
+ (setq edit-indirect--overlay nil)
+ ...)
+#+end_src
+
+*Add Display Buffer Error Handling*
+Check display-buffer return value:
+#+begin_src emacs-lisp
+(defun edit-indirect--display-buffer (buffer)
+ (with-current-buffer buffer
+ (setq-local edit-indirect--should-quit-window t))
+ (let ((window (display-buffer buffer)))
+ (if window
+ (select-window window)
+ (switch-to-buffer buffer)))
+ nil)
+#+end_src
+
+*** Medium Implementation
+
+*Optimize Overlay Search*
+Use a more efficient search strategy:
+#+begin_src emacs-lisp
+(defun edit-indirect--search-for-edit-indirect (beg end)
+ (cl-loop for overlay in (overlays-in beg end)
+ when (overlay-get overlay 'edit-indirect-buffer)
+ return overlay))
+#+end_src
+
+*Add Undo Boundary Management*
+Add undo boundaries to make commits atomic:
+#+begin_src emacs-lisp
+(defun edit-indirect--commit ()
+ (undo-boundary)
+ ;; ... rest of commit logic ...
+ (undo-boundary))
+#+end_src
+
+*Improve Buffer Naming*
+Make buffer names more informative:
+#+begin_src emacs-lisp
+(defun edit-indirect--create-indirect-buffer (beg end overlay)
+ (let* ((mode-name (symbol-name major-mode))
+ (buffer-name (format "*edit-indirect %s [%s]*"
+ (buffer-name)
+ (replace-regexp-in-string "-mode$" "" mode-name))))
+ ...))
+#+end_src
+
+*** Hard Implementation
+
+*Add Support for Nested Indirect Edits*
+Allow editing a region within an already indirect buffer by tracking parent chain.
+
+*Implement Diff Preview*
+Add a command to show differences between the indirect buffer and parent region before committing:
+#+begin_src emacs-lisp
+(defun edit-indirect-diff-preview ()
+ "Show differences between indirect buffer and parent region."
+ (interactive)
+ ;; Implementation using diff-mode
+ )
+#+end_src
+
+*Add Persistent Session Support*
+Save and restore indirect editing sessions across Emacs restarts using desktop.el integration.
+
+*Implement Conflict Resolution*
+Handle cases where the parent buffer was modified while editing in the indirect buffer, similar to version control merge conflicts.
+
+*Add Support for Multiple Regions*
+Allow editing multiple non-contiguous regions in a single indirect buffer, useful for refactoring similar code blocks simultaneously.
+
** TODO [#B] Get Tufte.css working and as a separate entry
Below is one way to get Org-mode’s HTML exporter to play nicely with Tufte-CSS. The basic recipe is:
1. Inject Tufte’s stylesheet into every HTML export
@@ -1397,136 +1582,7 @@ reverso.el is a package of mine that provides Emacs interface for https://revers
(setq reverso-languages '(russian english german spanish french portuguese))
(reverso-history-mode))
#+end_src
-* Emacs Config Ideas/References
-** Edit Indirect (Custom Code) Bugs and Improvements
-*** Bugs (by Severity)
-
-*** High Severity
-
-*Race Condition in =edit-indirect--commit=*
-The commit function modifies the parent buffer while iterating through hooks that may also modify the buffer. The use of markers helps, but there's still a potential race condition if hooks modify the region being replaced. The =save-match-data= approach with markers could fail if the hooks significantly alter the buffer structure between marker creation and replacement.
-
-*Potential Data Loss in =edit-indirect--clean-up=*
-The function calls =delete-overlay= before setting =edit-indirect--overlay= to nil. If =delete-overlay= signals an error (e.g., if the overlay was already deleted), the overlay reference won't be cleared, potentially causing infinite loops in =edit-indirect--abort-on-kill-buffer=.
-
-*** Medium Severity
-
-*Missing Buffer Validation in =edit-indirect--commit=*
-The function doesn't check if the parent buffer still exists before attempting to modify it. If the parent buffer was killed while the indirect buffer was being edited, this will cause an error.
-
-*Hook Execution Order Issue*
-The =edit-indirect--rebind-save-hooks= function is added to =after-change-major-mode-hook= globally but never removed. This means it will be called for every buffer that changes major mode, not just edit-indirect buffers, causing unnecessary overhead.
-
-*** Low Severity
-
-*Inefficient Overlay Search in =edit-indirect--search-for-edit-indirect=*
-The function uses =overlays-in= which returns all overlays, then filters them. For buffers with many overlays, this is inefficient.
-
-*Missing Error Handling in =edit-indirect--display-buffer=*
-The function doesn't handle cases where =display-buffer= returns nil (which can happen if all windows are dedicated or display is inhibited).
-
-*** Recommended Improvements
-
-*** Easy Implementation
-
-*Add Buffer Existence Check*
-Add a check in =edit-indirect--commit= to ensure the parent buffer still exists:
-#+begin_src emacs-lisp
-(unless (buffer-live-p (overlay-buffer edit-indirect--overlay))
- (error "Parent buffer no longer exists"))
-#+end_src
-
-*Improve Hook Management*
-Remove the global hook when not needed:
-#+begin_src emacs-lisp
-(defun edit-indirect--rebind-save-hooks ()
- (when (edit-indirect-buffer-indirect-p)
- (setq buffer-offer-save t)
- (add-hook 'write-contents-functions #'edit-indirect--commit-on-save nil t)
- ;; Remove global hook after use
- (remove-hook 'after-change-major-mode-hook #'edit-indirect--rebind-save-hooks)))
-#+end_src
-
-*Add Safety to Clean-up*
-Wrap cleanup operations in condition-case:
-#+begin_src emacs-lisp
-(defun edit-indirect--clean-up ()
- (condition-case nil
- (delete-overlay edit-indirect--overlay)
- (error nil))
- (setq edit-indirect--overlay nil)
- ...)
-#+end_src
-
-*Add Display Buffer Error Handling*
-Check display-buffer return value:
-#+begin_src emacs-lisp
-(defun edit-indirect--display-buffer (buffer)
- (with-current-buffer buffer
- (setq-local edit-indirect--should-quit-window t))
- (let ((window (display-buffer buffer)))
- (if window
- (select-window window)
- (switch-to-buffer buffer)))
- nil)
-#+end_src
-
-*** Medium Implementation
-
-*Optimize Overlay Search*
-Use a more efficient search strategy:
-#+begin_src emacs-lisp
-(defun edit-indirect--search-for-edit-indirect (beg end)
- (cl-loop for overlay in (overlays-in beg end)
- when (overlay-get overlay 'edit-indirect-buffer)
- return overlay))
-#+end_src
-
-*Add Undo Boundary Management*
-Add undo boundaries to make commits atomic:
-#+begin_src emacs-lisp
-(defun edit-indirect--commit ()
- (undo-boundary)
- ;; ... rest of commit logic ...
- (undo-boundary))
-#+end_src
-
-*Improve Buffer Naming*
-Make buffer names more informative:
-#+begin_src emacs-lisp
-(defun edit-indirect--create-indirect-buffer (beg end overlay)
- (let* ((mode-name (symbol-name major-mode))
- (buffer-name (format "*edit-indirect %s [%s]*"
- (buffer-name)
- (replace-regexp-in-string "-mode$" "" mode-name))))
- ...))
-#+end_src
-
-*** Hard Implementation
-
-*Add Support for Nested Indirect Edits*
-Allow editing a region within an already indirect buffer by tracking parent chain.
-
-*Implement Diff Preview*
-Add a command to show differences between the indirect buffer and parent region before committing:
-#+begin_src emacs-lisp
-(defun edit-indirect-diff-preview ()
- "Show differences between indirect buffer and parent region."
- (interactive)
- ;; Implementation using diff-mode
- )
-#+end_src
-
-*Add Persistent Session Support*
-Save and restore indirect editing sessions across Emacs restarts using desktop.el integration.
-
-*Implement Conflict Resolution*
-Handle cases where the parent buffer was modified while editing in the indirect buffer, similar to version control merge conflicts.
-
-*Add Support for Multiple Regions*
-Allow editing multiple non-contiguous regions in a single indirect buffer, useful for refactoring similar code blocks simultaneously.
-
-** config.org · master · jdm204 / dotfiles · GitLab
+** Install Magit TODOs
#+BEGIN_QUOTE
It’s nice for magit to display in-tree TODOs in the status buffer:
@@ -1538,7 +1594,7 @@ It’s nice for magit to display in-tree TODOs in the status buffer:
#+END_QUOTE
[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]]
Captured On: [2025-10-15 Wed 18:40]
-** config.org · master · jdm204 / dotfiles · GitLab
+** Move from company to corfu
#+BEGIN_QUOTE
Completion
A completion framework—I used to use company but corfu seems to integrate with stock Emacs better.
@@ -1567,60 +1623,56 @@ Use more completion at point functions from cape. dabbrev completion means that
#+END_QUOTE
[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]]
Captured On: [2025-10-15 Wed 18:37]
-** config.org · master · jdm204 / dotfiles · GitLab
+** Config recentf to exclude mail
#+BEGIN_QUOTE
-Sometimes you want dummy text, say if you’re testing layouts for a poster but you haven’t written the text yet. Look no further than lorem-ipsum.
+recentf provides a persistent list of recently-opened files.
+
+(use-package recentf
+ :ensure nil
+ :init (recentf-mode)
+ :config
+ (setq recentf-exclude '("Maildir")))
-(use-package lorem-ipsum
- :init
- (setq
- lorem-ipsum-sentence-separator " "
- lorem-ipsum-list-bullet "- ")
- ;; map from C-c l
- (lorem-ipsum-use-default-bindings))
#+END_QUOTE
[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]]
-Captured On: [2025-10-15 Wed 18:37]
-** config.org · master · jdm204 / dotfiles · GitLab
+Captured On: [2025-10-15 Wed 18:36]
+** consider installing minimap
#+BEGIN_QUOTE
-Tree-sitter grammar autoinstallation:
+A mini-map (miniaturised window showing more of the buffer than the main window) is nice for longer documents:
-(use-package treesit-auto
+(use-package minimap
:custom
- (treesit-auto-install 'prompt)
- :config
- (treesit-auto-add-to-auto-mode-alist 'all)
- (global-treesit-auto-mode))
+ (minimap-major-modes '(org-mode prog-mode))
+ (minimap-window-location 'right)
+ :bind ("C-+" . minimap-mode))
#+END_QUOTE
[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]]
-Captured On: [2025-10-15 Wed 18:37]
-** config.org · master · jdm204 / dotfiles · GitLab
+Captured On: [2025-10-15 Wed 18:31]
+** check into org-download for drag/drop images into org-buffers
#+BEGIN_QUOTE
-recentf provides a persistent list of recently-opened files.
-
-(use-package recentf
- :ensure nil
- :init (recentf-mode)
- :config
- (setq recentf-exclude '("Maildir")))
+Drag and drop images into Org buffers to insert links:
+(use-package org-download
+ :custom
+ (org-download-image-dir "./img"))
#+END_QUOTE
[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]]
-Captured On: [2025-10-15 Wed 18:36]
+Captured On: [2025-10-15 Wed 18:30]
+* Emacs Config Ideas/References
** config.org · master · jdm204 / dotfiles · GitLab
#+BEGIN_QUOTE
-I like to have a keybind to do the opposite of fill-paragraph. It’s taken from Stefan Monnier.
+Sometimes you want dummy text, say if you’re testing layouts for a poster but you haven’t written the text yet. Look no further than lorem-ipsum.
-(defun unfill-paragraph (&optional region)
- "Takes a multi-line paragraph and turns it into a single line"
- (interactive (progn (barf-if-buffer-read-only) '(t)))
- (let ((fill-column (point-max))
- (emacs-lisp-docstring-fill-column t))
- (fill-paragraph nil region)))
-(define-key global-map "\M-Q" 'unfill-paragraph)
+(use-package lorem-ipsum
+ :init
+ (setq
+ lorem-ipsum-sentence-separator " "
+ lorem-ipsum-list-bullet "- ")
+ ;; map from C-c l
+ (lorem-ipsum-use-default-bindings))
#+END_QUOTE
[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]]
-Captured On: [2025-10-15 Wed 18:35]
+Captured On: [2025-10-15 Wed 18:37]
** config.org · master · jdm204 / dotfiles · GitLab
#+BEGIN_QUOTE
I think a vertical “chooser” interface is key, it get used a lot due to find-file, buffer switching etc. I’m happy with vertico, which I switched to from selectrum because it appeared to rely more on built-in Emacs functionality which I think is a good thing for ecosystem compatability and the sustainability of Emacs as a platform in the future.
@@ -1706,30 +1758,6 @@ marginalia takes advantage of vertical choosers by annotating candidates with ex
Captured On: [2025-10-15 Wed 18:34]
** config.org · master · jdm204 / dotfiles · GitLab
#+BEGIN_QUOTE
-Showing the location of the mark, probably helps with learning to use mark better.
-
-(use-package visible-mark
- :init
- (global-visible-mark-mode)
- :custom
- (visible-mark-faces '(visible-mark-face1 visible-mark-face2))
- (visible-mark-forward-faces '(visible-mark-face1 visible-mark-face2))
- (visible-mark-max 2))
-#+END_QUOTE
-[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]]
-Captured On: [2025-10-15 Wed 18:33]
-** config.org · master · jdm204 / dotfiles · GitLab
-#+BEGIN_QUOTE
-which-key got built into Emacs, it shows potential full keybinds when you enter a partial one, which is great for discoverability and learning.
-
-(use-package which-key
- :ensure nil
- :init (which-key-mode))
-#+END_QUOTE
-[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]]
-Captured On: [2025-10-15 Wed 18:32]
-** config.org · master · jdm204 / dotfiles · GitLab
-#+BEGIN_QUOTE
Also show icons in find-file, consult-buffer etc.
(use-package all-the-icons-completion
@@ -1742,28 +1770,6 @@ Also show icons in find-file, consult-buffer etc.
Captured On: [2025-10-15 Wed 18:32]
** config.org · master · jdm204 / dotfiles · GitLab
#+BEGIN_QUOTE
-A mini-map (miniaturised window showing more of the buffer than the main window) is nice for longer documents:
-
-(use-package minimap
- :custom
- (minimap-major-modes '(org-mode prog-mode))
- (minimap-window-location 'right)
- :bind ("C-+" . minimap-mode))
-#+END_QUOTE
-[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]]
-Captured On: [2025-10-15 Wed 18:31]
-** config.org · master · jdm204 / dotfiles · GitLab
-#+BEGIN_QUOTE
-Drag and drop images into Org buffers to insert links:
-
-(use-package org-download
- :custom
- (org-download-image-dir "./img"))
-#+END_QUOTE
-[[https://gitlab.com/jdm204/dotfiles/-/blob/master/config.org][config.org · master · jdm204 / dotfiles · GitLab]]
-Captured On: [2025-10-15 Wed 18:30]
-** config.org · master · jdm204 / dotfiles · GitLab
-#+BEGIN_QUOTE
I prefer a hybrid of plain text and WYSIWYG, so only show org emphasis markers if point is on the word.
(use-package org-appear
@@ -1997,9 +2003,6 @@ Captured On: [2025-09-24 Wed 23:53]
** Battleship Game in Emacs
[[http://www.catb.org/~esr/bs/][Resource page for bs 2.13]]
Captured On: [2025-09-24 Wed 00:38]
-** BrainCurses Emacs Mastermind Game
-[[https://sourceforge.net/projects/braincurses/][BrainCurses download | SourceForge.net]]
-Captured On: [2025-09-24 Wed 00:38]
** pinoaffe/org-vcard: Export and import vCards from within GNU Emacs' Org mode.
[[https://github.com/pinoaffe/org-vcard][pinoaffe/org-vcard: Export and import vCards from within GNU Emacs' Org mode.]]
Captured On: [2025-09-23 Tue 10:46]
@@ -5728,3 +5731,6 @@ Captured On: [2025-10-17 Fri 11:55]
** rougier/mu4e-dashboard: A dashboard for mu4e (mu for emacs)
[[https://github.com/rougier/mu4e-dashboard][rougier/mu4e-dashboard: A dashboard for mu4e (mu for emacs)]]
Captured On: [2025-10-17 Fri 11:48]
+** beacoder/stock-tracker: Track stock price in Emacs
+[[https://github.com/beacoder/stock-tracker][beacoder/stock-tracker: Track stock price in Emacs]]
+Captured On: [2025-10-24 Fri 23:36]