Commit77d06c18Recorded18 Apr 2026Repositorysigil-lsp

sigil-lsp: merge import quick-fix into existing (import ...) clause

Message

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.

Changed
 src/sigil/lsp/code-actions.sgl | 140 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------
 1 file changed, 97 insertions(+), 43 deletions(-)
Diff
src/sigil/lsp/code-actions.sglmodified
@@ -73,17 +73,105 @@
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)))
+76
(edit (import-insertion-edit text mod)))
77
`((title . ,(format "Import ~a from ~a" name mod))
78
(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)))))
+79
(edit . ,(workspace-edit uri (list edit)))
80
(isPreferred . #t))))
81
+82
;; Compute a single TextEdit that either:
+83
;; - extends the first existing `(import ...)` form with a new
+84
;; module spec aligned under the existing ones, or
+85
;; - inserts a fresh top-level `(import MOD)\n` at the top of
+86
;; the file if no import clause exists.
+87
;; TEXT is the current buffer contents, MOD is e.g. "(sigil io)".
+88
(define (import-insertion-edit text mod)
+89
(let ((found (find-first-import-end text)))
+90
(if found
+91
(let* ((close-pos (car found))
+92
(indent-col (cadr found))
+93
(indent (make-indent indent-col))
+94
(pos (offset->pos text close-pos)))
+95
;; Insert "\n<indent><mod>" right before the closing paren.
+96
`((range . ,(range pos pos))
+97
(newText . ,(string-append "\n" indent mod))))
+98
;; Fallback: no import form — put one at the top of the file.
+99
`((range . ,(range (position 0 0) (position 0 0)))
+100
(newText . ,(string-append "(import " mod ")\n"))))))
+101
+102
;; Scan TEXT for the first `(import ` form. On success returns
+103
;; (list CLOSE-OFFSET INDENT-COL) where CLOSE-OFFSET is the offset
+104
;; of the final ')' of the (import ...) form, and INDENT-COL is the
+105
;; column the next module spec should align to. Returns #f if no
+106
;; import clause is present.
+107
(define (find-first-import-end text)
+108
(let* ((marker "(import ")
+109
(mlen (string-length marker))
+110
(start (string-find text marker)))
+111
(and start
+112
(let ((close (matching-close text start)))
+113
(and close
+114
(list close (column-at text (+ start mlen))))))))
+115
+116
;; Given an offset to a '(' in TEXT, return the offset of its
+117
;; matching ')', or #f. Skips over nested parens, strings, and
+118
;; line comments.
+119
(define (matching-close text open)
+120
(let ((len (string-length text)))
+121
(let loop ((i (+ open 1)) (depth 1))
+122
(cond
+123
((>= i len) #f)
+124
(else
+125
(let ((c (string-ref text i)))
+126
(cond
+127
((char=? c #\() (loop (+ i 1) (+ depth 1)))
+128
((char=? c #\))
+129
(if (= depth 1) i (loop (+ i 1) (- depth 1))))
+130
((char=? c #\") (loop (skip-string text (+ i 1)) depth))
+131
((char=? c #\;) (loop (skip-line text (+ i 1)) depth))
+132
(else (loop (+ i 1) depth)))))))))
+133
+134
(define (skip-string text i)
+135
(let ((len (string-length text)))
+136
(let loop ((i i))
+137
(cond
+138
((>= i len) i)
+139
((char=? (string-ref text i) #\\) (loop (+ i 2)))
+140
((char=? (string-ref text i) #\") (+ i 1))
+141
(else (loop (+ i 1)))))))
+142
+143
(define (skip-line text i)
+144
(let ((len (string-length text)))
+145
(let loop ((i i))
+146
(cond
+147
((>= i len) i)
+148
((char=? (string-ref text i) #\newline) i)
+149
(else (loop (+ i 1)))))))
+150
+151
;; Column of OFFSET within TEXT (0-based).
+152
(define (column-at text offset)
+153
(let loop ((i (- offset 1)) (col 0))
+154
(cond
+155
((< i 0) col)
+156
((char=? (string-ref text i) #\newline) col)
+157
(else (loop (- i 1) (+ col 1))))))
+158
+159
(define (make-indent n)
+160
(let loop ((i 0) (acc ""))
+161
(if (>= i n) acc (loop (+ i 1) (string-append acc " ")))))
+162
+163
;; Convert a byte offset in TEXT to an LSP Position alist.
+164
(define (offset->pos text offset)
+165
(let loop ((i 0) (line 0) (col 0))
+166
(cond
+167
((>= i offset) (position line col))
+168
((>= i (string-length text)) (position line col))
+169
(else
+170
(let ((c (string-ref text i)))
+171
(if (char=? c #\newline)
+172
(loop (+ i 1) (+ line 1) 0)
+173
(loop (+ i 1) line (+ col 1))))))))
+174
175
;; Collect module-name strings from `(import ...)` clauses in TEXT.
176
;; Matches both top-level `(import ...)` forms and imports inside a
177
;; `(define-library ...)` form.
@@ -236,48 +324,14 @@
324
325
(define (make-import-action uri text diag result)
326
(let* ((mod (search-result-module result))
239
(import-line (string-append "(import " mod ")\n"))
240
(insert-at (insert-import-position text)))
+327
(edit (import-insertion-edit text mod)))
328
`((title . ,(format "Import ~a from ~a"
329
(search-result-name result) mod))
330
(kind . ,kind-quickfix)
331
(diagnostics . ,(list diag))
245
(edit . ,(workspace-edit uri
246
(list `((range . ,(range
247
(position insert-at 0)
248
(position insert-at 0)))
249
(newText . ,import-line)))))
+332
(edit . ,(workspace-edit uri (list edit)))
333
(isPreferred . #f))))
334
252
;; Choose a line to insert a new top-level (import ...) form. We pick
253
;; the line following the last existing `(import` in the file, or
254
;; line 0 if none exists.
255
(define (insert-import-position text)
256
(let* ((len (string-length text))
257
(last-line 0)
258
(line 0))
259
(let loop ((i 0))
260
(when (< i len)
261
(when (and (char=? (string-ref text i) #\()
262
(starts-with-at? text i "(import"))
263
(set! last-line line))
264
(when (char=? (string-ref text i) #\newline)
265
(set! line (+ line 1)))
266
(loop (+ i 1))))
267
(if (> last-line 0) (+ last-line 1) 0)))
268
269
(define (starts-with-at? text i s)
270
(let ((slen (string-length s))
271
(len (string-length text)))
272
(and (<= (+ i slen) len)
273
(let loop ((k 0))
274
(cond
275
((>= k slen) #t)
276
((char=? (string-ref text (+ i k))
277
(string-ref s k))
278
(loop (+ k 1)))
279
(else #f))))))
280
335
;; "Unbound variable: foo" / "Undefined: foo" → "foo"
336
(define (extract-symbol-from-message msg)
337
(let ((idx (last-colon msg)))