sigil-lsp: merge import quick-fix into existing (import ...) clause
Previously the "Import X from M" quick-fix always inserted a fresh top-level (import M) line, producing files like:
(define-library (sigil socket)
(import (sigil string))
(import (sigil io)) ; dangling at column 0
(export ...)That broke alignment (and, for most callers, the define-library contract of a single import clause). Instead:
- Scan for the first (import …) form with a paren-matching walk that skips strings and line comments. - Compute the column where existing module specs begin (right after (import ). - Insert the new module spec on a new line at that column, just before the closing paren of the clause.
Fallback remains: if the buffer has no (import …) at all, insert a fresh (import M)\n at the top.
Also switches from a hand-rolled find-substring/substring=? to (sigil string)'s string-find, per review — the stdlib version is O(n+m) and already tuned; my inline version turned out to have an infinite-loop bug that hung the module anyway.
src/sigil/lsp/code-actions.sgl | 140 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------
1 file changed, 97 insertions(+), 43 deletions(-)src/sigil/lsp/code-actions.sglmodified
(define (make-import-action-for uri text result name) (let* ((mod (search-result-module result)) (import-line (string-append "(import " mod ")\n")) (insert-at (insert-import-position text))) (edit (import-insertion-edit text mod))) `((title . ,(format "Import ~a from ~a" name mod)) (kind . ,kind-quickfix) (edit . ,(workspace-edit uri (list `((range . ,(range (position insert-at 0) (position insert-at 0))) (newText . ,import-line))))) (edit . ,(workspace-edit uri (list edit))) (isPreferred . #t)))) ;; Compute a single TextEdit that either: ;; - extends the first existing `(import ...)` form with a new ;; module spec aligned under the existing ones, or ;; - inserts a fresh top-level `(import MOD)\n` at the top of ;; the file if no import clause exists. ;; TEXT is the current buffer contents, MOD is e.g. "(sigil io)". (define (import-insertion-edit text mod) (let ((found (find-first-import-end text))) (if found (let* ((close-pos (car found)) (indent-col (cadr found)) (indent (make-indent indent-col)) (pos (offset->pos text close-pos))) ;; Insert "\n<indent><mod>" right before the closing paren. `((range . ,(range pos pos)) (newText . ,(string-append "\n" indent mod)))) ;; Fallback: no import form — put one at the top of the file. `((range . ,(range (position 0 0) (position 0 0))) (newText . ,(string-append "(import " mod ")\n")))))) ;; Scan TEXT for the first `(import ` form. On success returns ;; (list CLOSE-OFFSET INDENT-COL) where CLOSE-OFFSET is the offset ;; of the final ')' of the (import ...) form, and INDENT-COL is the ;; column the next module spec should align to. Returns #f if no ;; import clause is present. (define (find-first-import-end text) (let* ((marker "(import ") (mlen (string-length marker)) (start (string-find text marker))) (and start (let ((close (matching-close text start))) (and close (list close (column-at text (+ start mlen)))))))) ;; Given an offset to a '(' in TEXT, return the offset of its ;; matching ')', or #f. Skips over nested parens, strings, and ;; line comments. (define (matching-close text open) (let ((len (string-length text))) (let loop ((i (+ open 1)) (depth 1)) (cond ((>= i len) #f) (else (let ((c (string-ref text i))) (cond ((char=? c #\() (loop (+ i 1) (+ depth 1))) ((char=? c #\)) (if (= depth 1) i (loop (+ i 1) (- depth 1)))) ((char=? c #\") (loop (skip-string text (+ i 1)) depth)) ((char=? c #\;) (loop (skip-line text (+ i 1)) depth)) (else (loop (+ i 1) depth))))))))) (define (skip-string text i) (let ((len (string-length text))) (let loop ((i i)) (cond ((>= i len) i) ((char=? (string-ref text i) #\\) (loop (+ i 2))) ((char=? (string-ref text i) #\") (+ i 1)) (else (loop (+ i 1))))))) (define (skip-line text i) (let ((len (string-length text))) (let loop ((i i)) (cond ((>= i len) i) ((char=? (string-ref text i) #\newline) i) (else (loop (+ i 1))))))) ;; Column of OFFSET within TEXT (0-based). (define (column-at text offset) (let loop ((i (- offset 1)) (col 0)) (cond ((< i 0) col) ((char=? (string-ref text i) #\newline) col) (else (loop (- i 1) (+ col 1)))))) (define (make-indent n) (let loop ((i 0) (acc "")) (if (>= i n) acc (loop (+ i 1) (string-append acc " "))))) ;; Convert a byte offset in TEXT to an LSP Position alist. (define (offset->pos text offset) (let loop ((i 0) (line 0) (col 0)) (cond ((>= i offset) (position line col)) ((>= i (string-length text)) (position line col)) (else (let ((c (string-ref text i))) (if (char=? c #\newline) (loop (+ i 1) (+ line 1) 0) (loop (+ i 1) line (+ col 1)))))))) ;; Collect module-name strings from `(import ...)` clauses in TEXT. ;; Matches both top-level `(import ...)` forms and imports inside a ;; `(define-library ...)` form. (define (make-import-action uri text diag result) (let* ((mod (search-result-module result)) (import-line (string-append "(import " mod ")\n")) (insert-at (insert-import-position text))) (edit (import-insertion-edit text mod))) `((title . ,(format "Import ~a from ~a" (search-result-name result) mod)) (kind . ,kind-quickfix) (diagnostics . ,(list diag)) (edit . ,(workspace-edit uri (list `((range . ,(range (position insert-at 0) (position insert-at 0))) (newText . ,import-line))))) (edit . ,(workspace-edit uri (list edit))) (isPreferred . #f)))) ;; Choose a line to insert a new top-level (import ...) form. We pick ;; the line following the last existing `(import` in the file, or ;; line 0 if none exists. (define (insert-import-position text) (let* ((len (string-length text)) (last-line 0) (line 0)) (let loop ((i 0)) (when (< i len) (when (and (char=? (string-ref text i) #\() (starts-with-at? text i "(import")) (set! last-line line)) (when (char=? (string-ref text i) #\newline) (set! line (+ line 1))) (loop (+ i 1)))) (if (> last-line 0) (+ last-line 1) 0))) (define (starts-with-at? text i s) (let ((slen (string-length s)) (len (string-length text))) (and (<= (+ i slen) len) (let loop ((k 0)) (cond ((>= k slen) #t) ((char=? (string-ref text (+ i k)) (string-ref s k)) (loop (+ k 1))) (else #f)))))) ;; "Unbound variable: foo" / "Undefined: foo" → "foo" (define (extract-symbol-from-message msg) (let ((idx (last-colon msg)))