Commit44c993b5Recorded18 Apr 2026Repositorysigil-lsp

sigil-lsp: offer import quick-fixes on cursor, not just on diagnostics

Message

Previously code actions only fired when a diagnostic with a message starting with "Unbound"/"Undefined" was present at the cursor, and sigil-lsp doesn't emit those diagnostics today. That meant M-x eglot-code-actions on a bare call-with-input-file returned nothing.

compute-code-actions now also runs a cursor-based pass: it reads the symbol under the request's range.start, searches the docs index for modules that export that exact name, filters out any module already imported in the buffer (either via a top-level (import ...) form or a (define-library ... (import ...)) clause), and emits one "Import X from M" quick-fix per remaining candidate.

Verified on socket.sgl: adding a new function that calls call-with-input-file surfaces a single action importing (sigil io).

Changed
 src/sigil/lsp/code-actions.sgl | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
 1 file changed, 95 insertions(+), 8 deletions(-)
Diff
src/sigil/lsp/code-actions.sglmodified
@@ -9,6 +9,7 @@
9
(define-library (sigil lsp code-actions)
10
(import (sigil core)
11
(sigil string)
+12
(sigil io)
13
(sigil dict)
14
(sigil docs search)
15
(sigil lsp positions)
@@ -24,16 +25,102 @@
25
(define (compute-code-actions store uri range-dict context-diagnostics)
26
(let* ((doc (doc-store-get store uri))
27
(text (and doc (document-text doc)))
27
(diags (or context-diagnostics '()))
28
(actions '()))
29
(when text
30
(for-each
31
(lambda (d)
32
(let ((as (diagnostic-actions uri text d)))
33
(set! actions (append actions as))))
34
(as-list diags)))
+28
(diags (or context-diagnostics '())))
+29
(if (not text)
+30
'()
+31
(append
+32
(all-diagnostic-actions uri text diags)
+33
(cursor-actions uri text range-dict)))))
+34
+35
(define (all-diagnostic-actions uri text diags)
+36
(let ((actions '()))
+37
(for-each
+38
(lambda (d)
+39
(let ((as (diagnostic-actions uri text d)))
+40
(set! actions (append actions as))))
+41
(as-list diags))
42
actions))
43
+44
;; Cursor-position actions: even when there's no diagnostic, if the
+45
;; symbol under point is exported by a module that isn't yet
+46
;; imported in this file, offer one "Import X from M" action per
+47
;; candidate module.
+48
(define (cursor-actions uri text range-dict)
+49
(let* ((start (and range-dict (dict-ref-any range-dict 'start)))
+50
(line0 (and start (dict-ref-any start 'line)))
+51
(char0 (and start (dict-ref-any start 'character))))
+52
(if (or (not (integer? line0)) (not (integer? char0)))
+53
'()
+54
(let ((name (word-at-position text line0 char0)))
+55
(if (or (not name) (string=? name ""))
+56
'()
+57
(import-suggestions-for-symbol uri text name))))))
+58
+59
(define (import-suggestions-for-symbol uri text name)
+60
(let* ((results (guard (e (else '())) (search-docs name)))
+61
(exact (filter (lambda (r)
+62
(and (string? (search-result-name r))
+63
(string=? (search-result-name r) name)))
+64
results))
+65
(imported (existing-imports text)))
+66
(filter-map
+67
(lambda (r)
+68
(let ((mod (search-result-module r)))
+69
(if (or (not mod) (member mod imported))
+70
#f
+71
(make-import-action-for uri text r name))))
+72
(limit 8 exact))))
+73
+74
(define (make-import-action-for uri text result name)
+75
(let* ((mod (search-result-module result))
+76
(import-line (string-append "(import " mod ")\n"))
+77
(insert-at (insert-import-position text)))
+78
`((title . ,(format "Import ~a from ~a" name mod))
+79
(kind . ,kind-quickfix)
+80
(edit . ,(workspace-edit uri
+81
(list `((range . ,(range
+82
(position insert-at 0)
+83
(position insert-at 0)))
+84
(newText . ,import-line)))))
+85
(isPreferred . #t))))
+86
+87
;; Collect module-name strings from `(import ...)` clauses in TEXT.
+88
;; Matches both top-level `(import ...)` forms and imports inside a
+89
;; `(define-library ...)` form.
+90
(define (existing-imports text)
+91
(guard (e (else '()))
+92
(let ((port (open-input-string text)))
+93
(let loop ((acc '()))
+94
(let ((form (read port)))
+95
(cond
+96
((eof-object? form) acc)
+97
((and (pair? form) (eq? (car form) 'import))
+98
(loop (append (import-modules (cdr form)) acc)))
+99
((and (pair? form) (eq? (car form) 'define-library))
+100
(loop (append (library-import-modules (cddr form)) acc)))
+101
(else (loop acc))))))))
+102
+103
(define (library-import-modules clauses)
+104
(cond
+105
((null? clauses) '())
+106
((and (pair? (car clauses)) (eq? (caar clauses) 'import))
+107
(append (import-modules (cdar clauses))
+108
(library-import-modules (cdr clauses))))
+109
(else (library-import-modules (cdr clauses)))))
+110
+111
;; Convert a list of import-set exprs to their module-name strings
+112
;; (matching what `search-result-module` returns).
+113
(define (import-modules specs)
+114
(map (lambda (spec) (format "~a" (unwrap-import-set spec)))
+115
specs))
+116
+117
(define (unwrap-import-set spec)
+118
(cond
+119
((and (pair? spec)
+120
(memq (car spec) '(only except prefix rename)))
+121
(unwrap-import-set (cadr spec)))
+122
(else spec)))
+123
124
(define (as-list v)
125
(cond
126
((null? v) '())