diff options
| author | Craig Jennings <c@cjennings.net> | 2026-04-23 01:47:52 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-04-23 01:47:52 -0500 |
| commit | fb2593ad55d0523dc211019f7ec856d5898d7c99 (patch) | |
| tree | 94c599d23f9e9bb3bca9d3cf0dc3c0043bc5dd39 /modules/system-utils.el | |
| parent | 73a95c7d1c5ba591a3444f92012be4a281e8d08b (diff) | |
| download | dotemacs-fb2593ad55d0523dc211019f7ec856d5898d7c99.tar.gz dotemacs-fb2593ad55d0523dc211019f7ec856d5898d7c99.zip | |
refactor(system-utils): extract testable open-file helpers
Extracts two pure helpers from cj/open-file-with-command and cj/xdg-open so the file-resolution and launcher-detection logic becomes testable without mocking process launchers.
New helpers:
- cj/--file-from-context returns a file path from the current context, resolving in priority order (explicit arg, buffer-file-name, dired file at point). Returns nil when none apply.
- cj/--open-with-is-launcher-p is a predicate for whether a command is a desktop launcher (xdg-open, open, start) that needs call-process detachment.
Both commands now delegate. cj/open-file-with-command uses cj/--file-from-context with read-file-name as the final fallback, plus cj/--open-with-is-launcher-p for the launcher dispatch. cj/xdg-open uses cj/--file-from-context with user-error as the "no file" fallback.
Behavior preserved. The existing system-utils test suites still pass, and the shape of each command's final effect is identical.
New tests, 14 cases across two per-function files:
- tests/test-system-utils--file-from-context.el covers: explicit wins over buffer-file, explicit wins over dired, buffer-file fallback, dired fallback, all-nil returns nil, explicit-nil uses chain, dired-mode-but-no-file-at-point.
- tests/test-system-utils--open-with-is-launcher-p.el covers: each of the three launcher names returns t, non-launcher returns nil, empty string returns nil, case-sensitive check, nil input returns nil.
Coverage: system-utils.el went from 10/52 (19.2%) to 15/52 (28.8%). The remaining uncovered lines are mostly in the process-launching paths of cj/open-file-with-command and cj/xdg-open. Those are testability-blocked. Mocking call-process, start-process-shell-command, and generate-new-buffer would give a lot of mock surface for low value. cj/server-shutdown is not meaningfully testable because it kills Emacs.
Diffstat (limited to 'modules/system-utils.el')
| -rw-r--r-- | modules/system-utils.el | 111 |
1 files changed, 57 insertions, 54 deletions
diff --git a/modules/system-utils.el b/modules/system-utils.el index 29076f6d..86c2ae16 100644 --- a/modules/system-utils.el +++ b/modules/system-utils.el @@ -55,43 +55,50 @@ ;;; ------------------------------- Open File With ------------------------------ ;; TASK: Favor this method over cj/open-this-file-with and add to custom buffer funcs +(defun cj/--file-from-context (&optional explicit-filename) + "Return a file path from the current context, or nil. +Resolves in priority order: + 1. EXPLICIT-FILENAME, if non-nil. + 2. `buffer-file-name' of the current buffer. + 3. The file at point if the current buffer is in dired-mode. +Returns nil when none of these yield a file." + (or explicit-filename + buffer-file-name + (and (derived-mode-p 'dired-mode) + (dired-file-name-at-point)))) + +(defun cj/--open-with-is-launcher-p (command) + "Return non-nil if COMMAND is a desktop launcher. +Launchers (xdg-open, open, start) need to be called with `call-process' +and a zero BUFFER argument so they fully detach from Emacs. Other +commands get `start-process-shell-command' so their output is visible." + (and (member command '("xdg-open" "open" "start")) t)) + (defun cj/open-file-with-command (command) "Open the current file with COMMAND. -Works in both Dired buffers and regular file buffers. The command runs -fully detached from Emacs." +Works in both Dired buffers and regular file buffers. Prompts for a +file only when neither context yields one. The command runs fully +detached from Emacs." (interactive "MOpen with command: ") - (let* ((file (cond - ;; In dired/dirvish mode, get file at point - ((derived-mode-p 'dired-mode) - (dired-get-file-for-visit)) - ;; In a regular file buffer - (buffer-file-name - buffer-file-name) - ;; Fallback - prompt for file - (t - (read-file-name "File to open: ")))) - ;; For xdg-open and similar launchers, we need special handling - (is-launcher (member command '("xdg-open" "open" "start")))) - ;; Validate file exists - (unless (and file (file-exists-p file)) - (error "No valid file found or selected")) - ;; Use different approaches for launchers vs regular commands - (if is-launcher - ;; For launchers, use call-process with 0 to fully detach - (progn - (call-process command nil 0 nil file) - (message "Opening %s with %s..." (file-name-nondirectory file) command)) - ;; For other commands, use start-process-shell-command for potential output - (let* ((output-buffer-name (format "*Open with %s: %s*" - command - (file-name-nondirectory file))) - (output-buffer (generate-new-buffer output-buffer-name))) - (start-process-shell-command - command - output-buffer - (format "%s %s" command (shell-quote-argument file))) - (message "Running %s on %s..." command (file-name-nondirectory file)))))) - + (let* ((file (or (cj/--file-from-context) + (read-file-name "File to open: ")))) + (unless (and file (file-exists-p file)) + (error "No valid file found or selected")) + (if (cj/--open-with-is-launcher-p command) + (progn + (call-process command nil 0 nil file) + (message "Opening %s with %s..." + (file-name-nondirectory file) command)) + (let* ((output-buffer-name (format "*Open with %s: %s*" + command + (file-name-nondirectory file))) + (output-buffer (generate-new-buffer output-buffer-name))) + (start-process-shell-command + command + output-buffer + (format "%s %s" command (shell-quote-argument file))) + (message "Running %s on %s..." + (file-name-nondirectory file) command))))) (defun cj/identify-external-open-command () "Return the OS-default \"open\" command for this host. @@ -107,26 +114,22 @@ Signals an error if the host is unsupported." Logs output and exit code to buffer *external-open.log*." (interactive) (let* ((file (expand-file-name - (or filename - buffer-file-name - (and (derived-mode-p 'dired-mode) (dired-file-name-at-point)) - (user-error "No file associated with this buffer")))) - (cmd (cj/identify-external-open-command)) - (logbuf (get-buffer-create "*external-open.log*"))) - (with-current-buffer logbuf - (goto-char (point-max)) - (insert (format-time-string "[%Y-%m-%d %H:%M:%S] ")) - (insert (format "Opening: %s\n" file))) - (cond - ;; Windows: let the shell handle association; fully detached. - ((env-windows-p) - (w32-shell-execute "open" file)) - ;; macOS/Linux: run the opener synchronously; it returns immediately. - (t - (call-process cmd nil 0 nil file) - (with-current-buffer logbuf - (insert " → Launched asynchronously\n")))) - nil)) + (or (cj/--file-from-context filename) + (user-error "No file associated with this buffer")))) + (cmd (cj/identify-external-open-command)) + (logbuf (get-buffer-create "*external-open.log*"))) + (with-current-buffer logbuf + (goto-char (point-max)) + (insert (format-time-string "[%Y-%m-%d %H:%M:%S] ")) + (insert (format "Opening: %s\n" file))) + (cond + ((env-windows-p) + (w32-shell-execute "open" file)) + (t + (call-process cmd nil 0 nil file) + (with-current-buffer logbuf + (insert " → Launched asynchronously\n")))) + nil)) ;;; ------------------------------ Server Shutdown ------------------------------ |
