/ 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