/ code-action-quick.el
code-action-quick.el
  1  ;;; code-action-quick.el --- Execute first available LSP code action -*- lexical-binding: t -*-
  2  
  3  ;; Copyright (C) 2026
  4  ;; Author: 
  5  ;; Version: 0.1.0
  6  ;; Package-Requires: ((emacs "27.1"))
  7  ;; Keywords: languages, tools, lsp
  8  ;; URL: 
  9  
 10  ;;; Commentary:
 11  
 12  ;; This package provides `code-action-quick', a command that automatically
 13  ;; executes the first (or only) available LSP code action at point.
 14  ;;
 15  ;; It supports both eglot (built-in since Emacs 29) and lsp-mode.
 16  ;;
 17  ;; Features:
 18  ;; - Detects active LSP client (eglot or lsp-mode) with per-buffer caching
 19  ;; - Queries code actions at point with configurable context lookback
 20  ;; - Filters to only quickfix and refactor.rewrite actions
 21  ;; - Executes first action or only if single action available
 22  
 23  ;;; Code:
 24  
 25  (require 'cl-lib)
 26  
 27  ;;; ============================================================================
 28  ;;; Customization
 29  ;;; ============================================================================
 30  
 31  (defgroup code-action-quick nil
 32    "Execute first available LSP code action."
 33    :group 'tools
 34    :prefix "caq-")
 35  
 36  (defcustom caq-lookback-lines 1
 37    "Number of previous lines to check for code actions.
 38  Set to 0 to only check at point."
 39    :type 'integer
 40    :group 'code-action-quick)
 41  
 42  (defcustom caq-execute-only-single nil
 43    "If non-nil, only execute when exactly one action is available.
 44  If nil, always execute the first (highest priority) action."
 45    :type 'boolean
 46    :group 'code-action-quick)
 47  
 48  (defcustom caq-allowed-action-kinds '("quickfix" "refactor.rewrite")
 49    "List of allowed code action kinds, in priority order.
 50  Only actions matching these kinds (or their sub-kinds) will be considered.
 51  First in list = highest priority."
 52    :type '(repeat string)
 53    :group 'code-action-quick)
 54  
 55  (defcustom caq-debug nil
 56    "If non-nil, print debug messages."
 57    :type 'boolean
 58    :group 'code-action-quick)
 59  
 60  ;;; ============================================================================
 61  ;;; Minor Mode: Indicator Customization
 62  ;;; ============================================================================
 63  
 64  (defface caq-indicator-face
 65    '((t (:width condensed :weight light :foreground "dark orange" :background "cornsilk")))
 66    "Face for code action indicator in modeline."
 67    :group 'code-action-quick)
 68  
 69  (defcustom caq-indicator-format "💡%s"
 70    "Format string for displaying code action in modeline.
 71  %s is replaced with the action title.  Leading space recommended for separation."
 72    :type 'string
 73    :group 'code-action-quick)
 74  
 75  (defcustom caq-indicator-delay 0.05
 76    "Seconds of idle time before checking for code actions.
 77  Lower values show actions faster but may impact typing performance."
 78    :type 'number
 79    :group 'code-action-quick)
 80  
 81  (defcustom caq-indicator-max-length 30
 82    "Maximum length of action title in indicator.
 83  Longer titles will be truncated with ellipsis."
 84    :type 'integer
 85    :group 'code-action-quick)
 86  
 87  ;;; ============================================================================
 88  ;;; LSP Client Detection (with per-buffer caching)
 89  ;;; ============================================================================
 90  
 91  (defvar-local caq--detected-client nil
 92    "Cached LSP client for current buffer.
 93  Value is one of: `eglot', `lsp-mode', or `none'.")
 94  
 95  (defun caq--detect-lsp-client ()
 96    "Detect which LSP client is active in current buffer.
 97  Returns `eglot', `lsp-mode', or nil.
 98  Result is cached per-buffer."
 99    (if (and caq--detected-client
100             (not (eq caq--detected-client 'unknown)))
101        ;; Return cached value (nil counts as 'none)
102        (unless (eq caq--detected-client 'none)
103          caq--detected-client)
104      ;; Detect and cache
105      (setq caq--detected-client
106            (cond
107             ;; Check eglot first (built-in, preferred)
108             ((and (fboundp 'eglot-current-server)
109                   (eglot-current-server))
110              'eglot)
111             ;; Check lsp-mode
112             ((and (bound-and-true-p lsp-mode)
113                   (fboundp 'lsp-workspaces)
114                   (lsp-workspaces))
115              'lsp-mode)
116             ;; No LSP client active
117             (t 'none)))
118      (unless (eq caq--detected-client 'none)
119        caq--detected-client)))
120  
121  (defun caq-clear-client-cache ()
122    "Clear the cached LSP client detection for current buffer.
123  Call this if you switch LSP clients."
124    (interactive)
125    (setq caq--detected-client nil)
126    (message "code-action-quick: client cache cleared"))
127  
128  ;;; ============================================================================
129  ;;; Minor Mode: Internal State
130  ;;; ============================================================================
131  
132  (defvar-local caq--indicator-timer nil
133    "Idle timer for code action indicator updates.")
134  
135  (defvar-local caq--indicator-pos nil
136    "Last position where indicator was updated.")
137  
138  (defvar-local caq--indicator-tick nil
139    "Buffer modification tick when indicator was last updated.
140  Used to detect when buffer content changed even if position didn't.")
141  
142  (defvar-local caq--mode-line-indicator nil
143    "Current modeline indicator string (with text properties).
144  This is evaluated by the modeline lighter.")
145  
146  (defvar-local caq--indicator-request-id 0
147    "ID of pending async request, for cancellation.
148  Incremented for each new request; responses with mismatched IDs are discarded.")
149  
150  (defvar caq-indicator-map
151    (let ((map (make-sparse-keymap)))
152      (define-key map [mode-line mouse-1] #'code-action-quick)
153      (define-key map [mode-line mouse-3] #'code-action-quick-show)
154      map)
155    "Keymap for code action indicator in modeline.
156  Mouse-1 executes the action, mouse-3 shows all available actions.")
157  
158  ;;; ============================================================================
159  ;;; Code Action Normalization
160  ;;; ============================================================================
161  
162  (cl-defstruct caq-action
163    "Normalized code action."
164    title      ; string - human readable
165    kind       ; string - e.g., "quickfix", "refactor.rewrite"
166    preferred  ; boolean - server marked as preferred
167    priority   ; integer - computed priority (lower = better)
168    raw        ; original action object from LSP client
169    client)    ; symbol - 'eglot or 'lsp-mode
170  
171  (defun caq--kind-priority (kind)
172    "Return priority for KIND based on `caq-allowed-action-kinds'.
173  Lower number = higher priority. Returns 999 if kind not allowed."
174    (if (null kind)
175        999
176      (let ((pos 0)
177            (found nil))
178        (dolist (allowed caq-allowed-action-kinds)
179          (when (and (not found)
180                     (or (string= kind allowed)
181                         (string-prefix-p (concat allowed ".") kind)))
182            (setq found pos))
183          (setq pos (1+ pos)))
184        (or found 999))))
185  
186  (defun caq--kind-allowed-p (kind)
187    "Return non-nil if KIND is in `caq-allowed-action-kinds'."
188    (< (caq--kind-priority kind) 999))
189  
190  (defun caq--normalize-eglot-action (action)
191    "Convert eglot ACTION (plist) to `caq-action'."
192    (let ((kind (plist-get action :kind)))
193      (make-caq-action
194       :title (plist-get action :title)
195       :kind kind
196       :preferred (eq t (plist-get action :isPreferred))
197       :priority (caq--kind-priority kind)
198       :raw action
199       :client 'eglot)))
200  
201  (defun caq--normalize-lsp-mode-action (action)
202    "Convert lsp-mode ACTION to `caq-action'."
203    (let ((kind (if (fboundp 'lsp:code-action-kind)
204                    (lsp:code-action-kind action)
205                  (plist-get action :kind))))
206      (make-caq-action
207       :title (if (fboundp 'lsp:code-action-title)
208                  (lsp:code-action-title action)
209                (plist-get action :title))
210       :kind kind
211       :preferred (if (fboundp 'lsp:code-action-is-preferred)
212                      (lsp:code-action-is-preferred action)
213                    (plist-get action :isPreferred))
214       :priority (caq--kind-priority kind)
215       :raw action
216       :client 'lsp-mode)))
217  
218  ;;; ============================================================================
219  ;;; Code Action Retrieval
220  ;;; ============================================================================
221  
222  (defun caq--get-actions-eglot (beg end)
223    "Get code actions from eglot between BEG and END."
224    ;; Try public API first (eglot-code-actions returns actions when non-interactive)
225    ;; Fall back to internal API for older eglot versions
226    (condition-case err
227        (cond
228         ;; Emacs 30+ / newer eglot: use public API with interactive=nil
229         ((fboundp 'eglot-code-actions)
230          (eglot-code-actions beg end nil nil))
231         ;; Older eglot: use internal API
232         ((fboundp 'eglot--code-actions)
233          (eglot--code-actions beg end))
234         (t nil))
235      (error
236       (when caq-debug
237         (message "caq: eglot error: %S" err))
238       nil)))
239  
240  (defun caq--get-actions-lsp-mode (beg end)
241    "Get code actions from lsp-mode between BEG and END."
242    (when (fboundp 'lsp-code-actions-at-point)
243      (condition-case err
244          ;; lsp-code-actions-at-point uses current region or point
245          (save-excursion
246            (goto-char beg)
247            ;; Set region if range specified
248            (when (and end (> end beg))
249              (push-mark end t t))
250            (lsp-code-actions-at-point))
251        (error
252         (when caq-debug
253           (message "caq: lsp-mode error: %S" err))
254         nil))))
255  
256  (defun caq--get-actions-at-pos (pos)
257    "Get code actions at position POS.
258  Returns a plist with :allowed (list of allowed `caq-action' structs)
259  and :rejected (list of rejected `caq-action' structs with reasons)."
260    (let* ((client (caq--detect-lsp-client))
261           (raw-actions
262            (pcase client
263              ('eglot (caq--get-actions-eglot pos pos))
264              ('lsp-mode (caq--get-actions-lsp-mode pos pos))
265              (_ nil)))
266           (normalizer
267            (pcase client
268              ('eglot #'caq--normalize-eglot-action)
269              ('lsp-mode #'caq--normalize-lsp-mode-action)
270              (_ #'identity)))
271           (allowed nil)
272           (rejected nil))
273      (when caq-debug
274        (message "caq: pos=%d client=%s raw-actions=%d" pos client (length raw-actions)))
275      ;; Normalize and categorize
276      (dolist (raw raw-actions)
277        (let* ((action (funcall normalizer raw))
278               (kind (caq-action-kind action)))
279          (if (caq--kind-allowed-p kind)
280              (push action allowed)
281            (push (cons action (format "kind '%s' not in caq-allowed-action-kinds" 
282                                       (or kind "<nil>")))
283                  rejected))))
284      (list :allowed (nreverse allowed)
285            :rejected (nreverse rejected))))
286  
287  ;;; ============================================================================
288  ;;; Context Lookback
289  ;;; ============================================================================
290  
291  (defun caq--collect-positions ()
292    "Collect positions to check for code actions.
293  Returns list of positions based on `caq-lookback-lines'.
294  Uses token-based stepping (via `forward-symbol') rather than
295  character-by-character to minimize LSP requests."
296    (require 'thingatpt)  ; for forward-symbol
297    (let ((positions (list (point)))
298          (limit-line (save-excursion
299                        (forward-line (- caq-lookback-lines))
300                        (line-beginning-position))))
301      ;; Step backward token-by-token until we reach the limit line
302      (save-excursion
303        (let ((prev-pos (point)))
304          (while (and (> (point) limit-line)
305                      (progn
306                        (forward-symbol -1)
307                        ;; Stop if we didn't move (at buffer start)
308                        (< (point) prev-pos)))
309            (push (point) positions)
310            (setq prev-pos (point)))))
311      (delete-dups positions)))
312  
313  (defun caq--collect-all-actions ()
314    "Collect all code actions from current position and lookback context.
315  Returns a plist with:
316    :allowed - sorted list of unique allowed `caq-action' structs
317    :rejected - list of (action . reason) for rejected actions
318    :positions-checked - number of positions checked"
319    (let ((all-allowed nil)
320          (all-rejected nil)
321          (seen-titles (make-hash-table :test 'equal))
322          (positions (caq--collect-positions)))
323      (when caq-debug
324        (message "caq: checking %d positions: %S" (length positions) positions))
325      ;; Collect from all positions
326      (dolist (pos positions)
327        (let ((result (caq--get-actions-at-pos pos)))
328          ;; Process allowed actions
329          (dolist (action (plist-get result :allowed))
330            ;; Deduplicate by title
331            (unless (gethash (caq-action-title action) seen-titles)
332              (puthash (caq-action-title action) t seen-titles)
333              (push action all-allowed)))
334          ;; Collect rejected (don't dedupe - show all)
335          (dolist (rejected (plist-get result :rejected))
336            (push rejected all-rejected))))
337      ;; Sort allowed: preferred first, then by priority (lower = better)
338      (setq all-allowed
339            (sort all-allowed
340                  (lambda (a b)
341                    (let ((pref-a (caq-action-preferred a))
342                          (pref-b (caq-action-preferred b))
343                          (pri-a (caq-action-priority a))
344                          (pri-b (caq-action-priority b)))
345                      (cond
346                       ;; Preferred always first
347                       ((and pref-a (not pref-b)) t)
348                       ((and pref-b (not pref-a)) nil)
349                       ;; Then by kind priority
350                       ((< pri-a pri-b) t)
351                       ((> pri-a pri-b) nil)
352                       ;; Finally alphabetical by title
353                       (t (string< (caq-action-title a) (caq-action-title b))))))))
354      (list :allowed all-allowed
355            :rejected all-rejected
356            :positions-checked (length positions))))
357  
358  ;;; ============================================================================
359  ;;; Code Action Execution
360  ;;; ============================================================================
361  
362  (defun caq--execute-action (action)
363    "Execute the code ACTION."
364    (let ((client (caq-action-client action))
365          (raw (caq-action-raw action)))
366      (when caq-debug
367        (message "caq: executing '%s' via %s" (caq-action-title action) client))
368      (pcase client
369        ('eglot
370         (when (fboundp 'eglot-execute)
371           (eglot-execute (eglot-current-server) raw)))
372        ('lsp-mode
373         (when (fboundp 'lsp-execute-code-action)
374           (lsp-execute-code-action raw))))))
375  
376  ;;; ============================================================================
377  ;;; Main Command
378  ;;; ============================================================================
379  
380  ;;;###autoload
381  (defun code-action-quick ()
382    "Execute the first available LSP code action at point.
383  
384  Behavior depends on `caq-execute-only-single':
385  - If non-nil: only execute when exactly one action available
386  - If nil: always execute the first (highest priority) action
387  
388  Only considers actions of kinds listed in `caq-allowed-action-kinds'
389  (by default: quickfix and refactor.rewrite).
390  
391  Also checks `caq-lookback-lines' previous lines for additional actions."
392    (interactive)
393    (let ((client (caq--detect-lsp-client)))
394      (unless client
395        (user-error "No LSP client active (neither eglot nor lsp-mode)"))
396      (let* ((result (caq--collect-all-actions))
397             (actions (plist-get result :allowed))
398             (rejected (plist-get result :rejected))
399             (positions-checked (plist-get result :positions-checked)))
400        (cond
401         ((null actions)
402          ;; No allowed actions - provide diagnostic info
403          (if rejected
404              (progn
405                (message "No allowed code actions (checked %d positions, %d actions rejected)"
406                         positions-checked (length rejected))
407                (when caq-debug
408                  (message "Rejected actions:")
409                  (dolist (r rejected)
410                    (let* ((action (car r))
411                           (reason (cdr r))
412                           (title (caq-action-title action)))
413                      (message "  - %s: %s" title reason)))))
414            (message "No code actions available (checked %d positions)" positions-checked)))
415         ((and caq-execute-only-single (> (length actions) 1))
416          (message "Multiple actions available (%d), not executing (caq-execute-only-single is t)"
417                   (length actions))
418          (when caq-debug
419            (dolist (a actions)
420              (message "  - [%s] %s%s"
421                       (or (caq-action-kind a) "?")
422                       (caq-action-title a)
423                       (if (caq-action-preferred a) " (preferred)" "")))))
424         (t
425          (caq--execute-action (car actions)))))))
426  
427  ;;;###autoload
428  (defun code-action-quick-show ()
429    "Show available code actions without executing.
430  Useful for debugging and understanding what actions are available."
431    (interactive)
432    (let ((client (caq--detect-lsp-client)))
433      (unless client
434        (user-error "No LSP client active"))
435      (let* ((result (caq--collect-all-actions))
436             (actions (plist-get result :allowed))
437             (rejected (plist-get result :rejected))
438             (positions-checked (plist-get result :positions-checked)))
439        (if (null actions)
440            (if rejected
441                (progn
442                  (message "No allowed actions (checked %d positions)" positions-checked)
443                  (message "Rejected actions (%d):" (length rejected))
444                  (dolist (r rejected)
445                    (let* ((action (car r))
446                           (reason (cdr r))
447                           (title (caq-action-title action)))
448                      (message "  - %s: %s" title reason))))
449              (message "No code actions available (checked %d positions)" positions-checked))
450          (message "Available code actions (%d, checked %d positions):"
451                   (length actions) positions-checked)
452          (dolist (a actions)
453            (message "  %d. [%s] %s%s"
454                     (1+ (cl-position a actions))
455                     (or (caq-action-kind a) "?")
456                     (caq-action-title a)
457                     (if (caq-action-preferred a) " ★" "")))
458          (when rejected
459            (message "Also %d rejected action(s) - use M-x caq-show-rejected for details"
460                     (length rejected)))))))
461  
462  ;;;###autoload
463  (defun caq-show-rejected ()
464    "Show code actions that were rejected by kind filtering.
465  This shows actions that exist but aren't in `caq-allowed-action-kinds'.
466  Useful for understanding why expected actions aren't being executed."
467    (interactive)
468    (let ((client (caq--detect-lsp-client)))
469      (unless client
470        (user-error "No LSP client active"))
471      (let* ((result (caq--collect-all-actions))
472             (allowed (plist-get result :allowed))
473             (rejected (plist-get result :rejected))
474             (positions-checked (plist-get result :positions-checked)))
475        (message "=== Code Action Diagnostic (checked %d positions) ===" positions-checked)
476        (message "Allowed kinds: %S" caq-allowed-action-kinds)
477        (message "")
478        (if rejected
479            (progn
480              (message "Rejected actions (%d):" (length rejected))
481              (dolist (r rejected)
482                (let* ((action (car r))
483                       (reason (cdr r)))
484                  (message "  ✗ [%s] %s"
485                           (or (caq-action-kind action) "<nil>")
486                           (caq-action-title action))
487                  (message "    Reason: %s" reason))))
488          (message "No rejected actions."))
489        (message "")
490        (if allowed
491            (progn
492              (message "Allowed actions (%d):" (length allowed))
493              (dolist (a allowed)
494                (message "  ✓ [%s] %s%s"
495                         (or (caq-action-kind a) "<nil>")
496                         (caq-action-title a)
497                         (if (caq-action-preferred a) " (preferred)" ""))))
498          (message "No allowed actions.")))))
499  
500  ;;;###autoload
501  (defun caq-reload ()
502    "Reload code-action-quick and restart the minor mode in all active buffers.
503  Useful during development to pick up changes including faces and modeline."
504    (interactive)
505    (let ((active-buffers nil)
506          (global-was-active (bound-and-true-p code-action-quick-global-mode)))
507      ;; Disable global mode first if active
508      (when global-was-active
509        (code-action-quick-global-mode -1))
510      ;; Find all buffers with the minor mode enabled and fully clean up
511      (dolist (buf (buffer-list))
512        (with-current-buffer buf
513          (when (bound-and-true-p code-action-quick-mode)
514            (push buf active-buffers)
515            ;; Manually clean up to ensure thorough removal
516            (remove-hook 'post-command-hook #'caq--schedule-indicator-update t)
517            (when (bound-and-true-p caq--indicator-timer)
518              (cancel-timer caq--indicator-timer))
519            (setq-local caq--indicator-timer nil)
520            (setq-local caq--mode-line-indicator nil)
521            (setq-local caq--indicator-pos nil)
522            (setq-local caq--indicator-tick nil)
523            (setq-local caq--detected-client nil)
524            ;; Remove from mode-line-misc-info
525            (setq mode-line-misc-info
526                  (cl-remove-if (lambda (x)
527                                  (and (consp x)
528                                       (eq (car x) :eval)
529                                       (eq (cadr x) 'caq--mode-line-indicator)))
530                                mode-line-misc-info))
531            (setq code-action-quick-mode nil)
532            (force-mode-line-update))))
533      ;; Unload old feature to ensure clean reload
534      (when (featurep 'code-action-quick)
535        (unload-feature 'code-action-quick t))
536      ;; Delete .elc if it exists, then reload from source, then recompile
537      (let* ((el-file (locate-library "code-action-quick" nil nil t))
538             (elc-file (when el-file (concat el-file "c")))
539             (had-elc (and elc-file (file-exists-p elc-file))))
540        (unless el-file
541          (user-error "Cannot find code-action-quick.el"))
542        (when had-elc
543          (delete-file elc-file)
544          (message "Deleted %s" elc-file))
545        (load el-file nil t t)
546        (message "Reloaded %s" el-file)
547        (when had-elc
548          (byte-compile-file el-file)
549          (message "Recompiled %s" elc-file)))
550      ;; Re-enable global mode if it was active
551      (when global-was-active
552        (code-action-quick-global-mode 1))
553      ;; Re-enable in all previously active buffers
554      (dolist (buf active-buffers)
555        (when (buffer-live-p buf)
556          (with-current-buffer buf
557            (code-action-quick-mode 1))))
558      (message "code-action-quick reloaded, minor mode restarted in %d buffer(s)"
559               (length active-buffers))))
560  
561  ;;; ============================================================================
562  ;;; Minor Mode: Indicator Functions
563  ;;; ============================================================================
564  
565  (defun caq--set-indicator (actions)
566    "Set the modeline indicator based on ACTIONS.
567  ACTIONS should be a list of `caq-action' structs, or nil."
568    (setq caq--mode-line-indicator
569          (when actions
570            (let* ((action (car actions))
571                   (title (caq-action-title action))
572                   (truncated (if (> (length title) caq-indicator-max-length)
573                                 (concat (substring title 0 (- caq-indicator-max-length 1)) "…")
574                               title))
575                   (indicator (format caq-indicator-format truncated)))
576              (propertize indicator
577                          'face 'caq-indicator-face
578                          'mouse-face 'mode-line-highlight
579                          'help-echo (format "mouse-1: %s\nmouse-3: show all actions" title)
580                          'local-map caq-indicator-map))))
581    (force-mode-line-update))
582  
583  (defun caq--clear-indicator ()
584    "Clear the modeline indicator."
585    (setq caq--mode-line-indicator nil)
586    (force-mode-line-update))
587  
588  (defun caq--update-indicator ()
589    "Update the indicator by fetching code actions.
590  Called by the idle timer."
591    (setq caq--indicator-timer nil)
592    (when (and (bound-and-true-p code-action-quick-mode)
593               (caq--detect-lsp-client))
594      ;; Check if position OR buffer content changed
595      (let ((pos (point))
596            (tick (buffer-modified-tick)))
597        (unless (and (eq pos caq--indicator-pos)
598                     (eq tick caq--indicator-tick))
599          (setq caq--indicator-pos pos)
600          (setq caq--indicator-tick tick)
601          (caq--fetch-actions-for-indicator-async)))))
602  
603  (defun caq--fetch-actions-for-indicator-async ()
604    "Fetch code actions asynchronously for the indicator.
605  Only queries at point (no lookback) to keep it fast and non-blocking.
606  Lookback is still used for the interactive `code-action-quick' command."
607    (let* ((buf (current-buffer))
608           ;; Handle nil from buffers created before reload
609           (request-id (setq caq--indicator-request-id
610                             (1+ (or caq--indicator-request-id 0))))
611           (client (caq--detect-lsp-client)))
612      (when caq-debug
613        (message "caq: async fetch #%d at pos %d" request-id (point)))
614      (pcase client
615        ('eglot (caq--fetch-indicator-eglot-async buf request-id))
616        ('lsp-mode (caq--fetch-indicator-lsp-mode-async buf request-id)))))
617  
618  (defun caq--fetch-indicator-eglot-async (buf request-id)
619    "Fetch code actions from eglot asynchronously for buffer BUF.
620  REQUEST-ID is used to discard stale results."
621    (when (and (fboundp 'eglot-current-server)
622               (fboundp 'jsonrpc-async-request))
623      (let ((server (eglot-current-server)))
624        (when server
625          (condition-case err
626              (let* ((pos (with-current-buffer buf (point)))
627                     (params (with-current-buffer buf
628                              (list :textDocument (eglot--TextDocumentIdentifier)
629                                    :range (list :start (eglot--pos-to-lsp-position pos)
630                                                 :end (eglot--pos-to-lsp-position pos))
631                                    :context (list :diagnostics
632                                                  (vconcat
633                                                   (cl-loop for diag in (flymake-diagnostics pos pos)
634                                                            when (cdr (assoc 'eglot-lsp-diag
635                                                                             (eglot--diag-data diag)))
636                                                            collect it)))))))
637                (jsonrpc-async-request
638                 server :textDocument/codeAction params
639                 :success-fn (lambda (actions)
640                              (caq--handle-indicator-response
641                               buf request-id (append actions nil)))
642                 :error-fn (lambda (_err)
643                            (caq--handle-indicator-response buf request-id nil))
644                 :timeout-fn (lambda ()
645                              (caq--handle-indicator-response buf request-id nil))))
646            (error
647             (when caq-debug
648               (message "caq: eglot async error: %S" err))
649             (caq--clear-indicator)))))))
650  
651  (defun caq--fetch-indicator-lsp-mode-async (buf request-id)
652    "Fetch code actions from lsp-mode asynchronously for buffer BUF.
653  REQUEST-ID is used to discard stale results."
654    (when (fboundp 'lsp-request-async)
655      (condition-case err
656          (with-current-buffer buf
657            (let ((params (lsp--text-document-code-action-params)))
658              (lsp-request-async
659               "textDocument/codeAction" params
660               (lambda (actions)
661                 (caq--handle-indicator-response
662                  buf request-id (append actions nil)))
663               :error-handler (lambda (_err)
664                               (caq--handle-indicator-response buf request-id nil))
665               :mode 'tick)))
666        (error
667         (when caq-debug
668           (message "caq: lsp-mode async error: %S" err))
669         (caq--clear-indicator)))))
670  
671  (defun caq--handle-indicator-response (buf request-id raw-actions)
672    "Handle async response with RAW-ACTIONS for buffer BUF.
673  Discards result if REQUEST-ID doesn't match current request."
674    (when (buffer-live-p buf)
675      (with-current-buffer buf
676        ;; Discard stale results
677        (if (not (eq request-id caq--indicator-request-id))
678            (when caq-debug
679              (message "caq: discarding stale response #%d (current is #%d)"
680                       request-id caq--indicator-request-id))
681          ;; Process current result
682          (if (null raw-actions)
683              (caq--clear-indicator)
684            (let* ((client (caq--detect-lsp-client))
685                   (normalizer (pcase client
686                                ('eglot #'caq--normalize-eglot-action)
687                                ('lsp-mode #'caq--normalize-lsp-mode-action)
688                                (_ #'identity)))
689                   (allowed nil))
690              ;; Normalize and filter
691              (dolist (raw raw-actions)
692                (let* ((action (funcall normalizer raw))
693                       (kind (caq-action-kind action)))
694                  (when (caq--kind-allowed-p kind)
695                    (push action allowed))))
696              ;; Sort: preferred first, then by priority
697              (setq allowed
698                    (sort (nreverse allowed)
699                          (lambda (a b)
700                            (let ((pref-a (caq-action-preferred a))
701                                  (pref-b (caq-action-preferred b))
702                                  (pri-a (caq-action-priority a))
703                                  (pri-b (caq-action-priority b)))
704                              (cond
705                               ((and pref-a (not pref-b)) t)
706                               ((and pref-b (not pref-a)) nil)
707                               ((< pri-a pri-b) t)
708                               (t nil))))))
709              (caq--set-indicator allowed)))))))
710  
711  (defun caq--schedule-indicator-update ()
712    "Schedule an indicator update after idle delay."
713    (when caq--indicator-timer
714      (cancel-timer caq--indicator-timer))
715    (setq caq--indicator-timer
716          (run-with-idle-timer caq-indicator-delay nil
717                               #'caq--update-indicator)))
718  
719  (defun caq-refresh-indicator ()
720    "Force immediate refresh of the indicator.
721  Useful after programmatic cursor movement where post-command-hook
722  doesn't fire, or when you need to ensure the indicator is up-to-date."
723    (interactive)
724    (setq caq--indicator-pos nil)  ; Force refetch
725    (setq caq--indicator-tick nil)
726    (when (caq--detect-lsp-client)
727      (caq--fetch-actions-for-indicator-async)))
728  
729  (defun caq--indicator-mode-enable ()
730    "Enable indicator mode functionality."
731    (add-hook 'post-command-hook #'caq--schedule-indicator-update nil t)
732    ;; Add indicator to mode-line-misc-info (appears on right side)
733    (add-to-list 'mode-line-misc-info '(:eval caq--mode-line-indicator) t)
734    ;; Initial update
735    (caq--schedule-indicator-update))
736  
737  (defun caq--indicator-mode-disable ()
738    "Disable indicator mode functionality."
739    (remove-hook 'post-command-hook #'caq--schedule-indicator-update t)
740    (when caq--indicator-timer
741      (cancel-timer caq--indicator-timer)
742      (setq caq--indicator-timer nil))
743    ;; Remove from mode-line-misc-info
744    (setq mode-line-misc-info (delete '(:eval caq--mode-line-indicator) mode-line-misc-info))
745    (caq--clear-indicator))
746  
747  ;;;###autoload
748  (define-minor-mode code-action-quick-mode
749    "Minor mode to display available code actions in modeline.
750  
751  When enabled, shows available quickfix/refactor actions as a
752  dynamic lighter in the modeline.  The indicator updates
753  automatically as you move the cursor.
754  
755  Click on the indicator to execute the action (mouse-1) or
756  show all available actions (mouse-3).
757  
758  Customize `caq-indicator-delay' to adjust responsiveness.
759  Customize `caq-indicator-format' to change the display format."
760    :lighter nil
761    :group 'code-action-quick
762    (if code-action-quick-mode
763        (caq--indicator-mode-enable)
764      (caq--indicator-mode-disable)))
765  
766  ;;; ============================================================================
767  ;;; Global Mode
768  ;;; ============================================================================
769  
770  (defun caq--maybe-enable-mode ()
771    "Enable `code-action-quick-mode' if an LSP client is active.
772  Intended for use in LSP hooks."
773    (when (and code-action-quick-global-mode
774               (not code-action-quick-mode)
775               (caq--detect-lsp-client))
776      (code-action-quick-mode 1)))
777  
778  (defun caq--setup-global-hooks ()
779    "Add hooks to enable `code-action-quick-mode' when LSP starts."
780    ;; eglot hook - runs when eglot starts managing a buffer
781    (add-hook 'eglot-managed-mode-hook #'caq--maybe-enable-mode)
782    ;; lsp-mode hook - runs when lsp-mode connects
783    (add-hook 'lsp-mode-hook #'caq--maybe-enable-mode)
784    (add-hook 'lsp-after-open-hook #'caq--maybe-enable-mode))
785  
786  (defun caq--teardown-global-hooks ()
787    "Remove hooks added by `caq--setup-global-hooks'."
788    (remove-hook 'eglot-managed-mode-hook #'caq--maybe-enable-mode)
789    (remove-hook 'lsp-mode-hook #'caq--maybe-enable-mode)
790    (remove-hook 'lsp-after-open-hook #'caq--maybe-enable-mode))
791  
792  (defun caq--enable-in-existing-buffers ()
793    "Enable `code-action-quick-mode' in all buffers with active LSP."
794    (dolist (buf (buffer-list))
795      (with-current-buffer buf
796        (caq--maybe-enable-mode))))
797  
798  (defun caq--disable-in-all-buffers ()
799    "Disable `code-action-quick-mode' in all buffers."
800    (dolist (buf (buffer-list))
801      (with-current-buffer buf
802        (when code-action-quick-mode
803          (code-action-quick-mode -1)))))
804  
805  ;;;###autoload
806  (define-minor-mode code-action-quick-global-mode
807    "Global minor mode to auto-enable `code-action-quick-mode' with LSP.
808  
809  When enabled, automatically turns on `code-action-quick-mode' in
810  any buffer that has eglot or lsp-mode active.  This provides the
811  modeline indicator showing available code actions.
812  
813  This mode hooks into `eglot-managed-mode-hook' and `lsp-mode-hook'
814  to detect when an LSP client becomes active."
815    :global t
816    :group 'code-action-quick
817    (if code-action-quick-global-mode
818        (progn
819          (caq--setup-global-hooks)
820          (caq--enable-in-existing-buffers))
821      (caq--teardown-global-hooks)
822      (caq--disable-in-all-buffers)))
823  
824  (provide 'code-action-quick)
825  ;;; code-action-quick.el ends here