Skip to content

Commit 9c8ec1d

Browse files
fix(lsp-mode): tolerate buffers whose major mode has no language spec (#2193)
* fix(lsp-mode): tolerate buffers whose major mode has no language spec get-language-spec asserted the per-symbol property was a spec instance, but its callers (buffer-language-spec, etc.) already use when-let against the result -- they expect nil for modes that should not activate LSP. The assertion crashed the editor command loop whenever a buffer's major mode lacked a registered spec. This is reachable in normal use. clojure-repl-mode (and similar listener-style modes) inherits from clojure-mode, which registers a spec. The parent activation fires *clojure-mode-hook* -> enable-lsp-mode, which starts an async workspace connect. By the time the workspace's :then callback fires text-document/did-open, the buffer's major mode has switched to clojure-repl-mode (no spec) and get-language-spec crashed. Even if the open was skipped, the buffer kept lsp-mode's before-change hook, so the next keystroke hit text-document/did-change -> find-workspace nil -> error. Push the guard down to where it belongs -- the LSP request functions themselves -- so every callsite stays a single, unconditional invocation: - get-language-spec returns nil for unregistered modes, matching the when-let contract its callers already assume. - find-workspace returns nil silently when language-id is nil, regardless of :errorp. A nil language-id means the buffer is not an LSP buffer -- not a runtime failure. - text-document/did-open, did-change, did-save, and did-close all wrap their request body in (when-let (workspace (buffer-workspace buffer nil)) ...), so a buffer that has no workspace (no spec, or workspace not yet connected) becomes a silent no-op instead of crashing on workspace-client nil. LSP remains fully functional for any buffer whose major mode is registered; non-LSP buffers (REPL transcripts, etc.) become no-ops on the relevant hooks. * test(lsp-mode): cover spec/workspace nil-tolerant lookups Two deftests guard the regressions the previous commit fixed: - get-language-spec returns nil for an unregistered mode (the path that previously asserted and crashed the editor command loop the first time a REPL-style buffer interacted with lsp-mode). - find-workspace returns nil for a nil language-id even with :errorp t (the default path that previously signaled "The NIL workspace is not found." on the next keystroke). Verified both fail when the source fix is reverted.
1 parent 8919e53 commit 9c8ec1d

4 files changed

Lines changed: 89 additions & 33 deletions

File tree

extensions/lsp-mode/lsp-mode.lisp

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -231,10 +231,15 @@ Use this when lsp-mode has side effects that you want to avoid."
231231
(setf (workspace-list-current-workspace workspace-list) workspace)))
232232

233233
(defun find-workspace (language-id &key (errorp t))
234-
(if-let (workspace-list (gethash language-id *workspace-list-per-language-id*))
235-
(workspace-list-current-workspace workspace-list)
236-
(when errorp
237-
(error "The ~A workspace is not found." language-id))))
234+
;; A nil LANGUAGE-ID means the buffer's current major mode has no
235+
;; registered language spec (e.g. a REPL mode whose parent had one).
236+
;; That is not an error condition -- the buffer simply does not
237+
;; participate in LSP. Return nil silently regardless of ERRORP.
238+
(when language-id
239+
(if-let (workspace-list (gethash language-id *workspace-list-per-language-id*))
240+
(workspace-list-current-workspace workspace-list)
241+
(when errorp
242+
(error "The ~A workspace is not found." language-id)))))
238243

239244
(defun buffer-workspace (buffer &optional (errorp t))
240245
(find-workspace (buffer-language-id buffer) :errorp errorp))
@@ -613,22 +618,24 @@ Use this when lsp-mode has side effects that you want to avoid."
613618
;;; Text Synchronization
614619

615620
(defun text-document/did-open (buffer)
616-
(request:request
617-
(workspace-client (buffer-workspace buffer))
618-
(make-instance 'lsp:text-document/did-open)
619-
(make-instance 'lsp:did-open-text-document-params
620-
:text-document (buffer-to-text-document-item buffer))))
621+
(when-let (workspace (buffer-workspace buffer nil))
622+
(request:request
623+
(workspace-client workspace)
624+
(make-instance 'lsp:text-document/did-open)
625+
(make-instance 'lsp:did-open-text-document-params
626+
:text-document (buffer-to-text-document-item buffer)))))
621627

622628
(defun text-document/did-change (buffer content-changes)
623-
(request:request
624-
(workspace-client (buffer-workspace buffer))
625-
(make-instance
626-
'lsp:text-document/did-change)
627-
(make-instance 'lsp:did-change-text-document-params
628-
:text-document (make-instance 'lsp:versioned-text-document-identifier
629-
:version (buffer-version buffer)
630-
:uri (buffer-uri buffer))
631-
:content-changes content-changes)))
629+
(when-let (workspace (buffer-workspace buffer nil))
630+
(request:request
631+
(workspace-client workspace)
632+
(make-instance
633+
'lsp:text-document/did-change)
634+
(make-instance 'lsp:did-change-text-document-params
635+
:text-document (make-instance 'lsp:versioned-text-document-identifier
636+
:version (buffer-version buffer)
637+
:uri (buffer-uri buffer))
638+
:content-changes content-changes))))
632639

633640
(defun provide-did-save-text-document-p (workspace)
634641
(let ((sync (lsp:server-capabilities-text-document-sync
@@ -644,20 +651,22 @@ Use this when lsp-mode has side effects that you want to avoid."
644651
nil))))))
645652

646653
(defun text-document/did-save (buffer)
647-
(when (provide-did-save-text-document-p (buffer-workspace buffer))
648-
(request:request
649-
(workspace-client (buffer-workspace buffer))
650-
(make-instance 'lsp:text-document/did-save)
651-
(make-instance 'lsp:did-save-text-document-params
652-
:text-document (make-text-document-identifier buffer)
653-
:text (buffer-text buffer)))))
654+
(when-let (workspace (buffer-workspace buffer nil))
655+
(when (provide-did-save-text-document-p workspace)
656+
(request:request
657+
(workspace-client workspace)
658+
(make-instance 'lsp:text-document/did-save)
659+
(make-instance 'lsp:did-save-text-document-params
660+
:text-document (make-text-document-identifier buffer)
661+
:text (buffer-text buffer))))))
654662

655663
(defun text-document/did-close (buffer)
656-
(request:request
657-
(workspace-client (buffer-workspace buffer))
658-
(make-instance 'lsp:text-document/did-close)
659-
(make-instance 'lsp:did-close-text-document-params
660-
:text-document (make-text-document-identifier buffer))))
664+
(when-let (workspace (buffer-workspace buffer nil))
665+
(request:request
666+
(workspace-client workspace)
667+
(make-instance 'lsp:text-document/did-close)
668+
(make-instance 'lsp:did-close-text-document-params
669+
:text-document (make-text-document-identifier buffer)))))
661670

662671
;;; publishDiagnostics
663672

extensions/lsp-mode/spec.lisp

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,14 @@
4747
:reader spec-mode)))
4848

4949
(defun get-language-spec (major-mode)
50+
"Return the registered language `spec' for MAJOR-MODE, or NIL when no
51+
spec has been registered. Callers like `buffer-language-spec' already
52+
use `when-let' against this result and rely on a NIL return for modes
53+
that should not activate LSP -- for example REPL buffers whose major
54+
mode inherits from a parent mode that did register a spec."
5055
(let ((spec (get major-mode 'spec)))
51-
(assert (typep spec 'spec))
52-
spec))
56+
(when (typep spec 'spec)
57+
spec)))
5358

5459
(defun register-language-spec (major-mode spec)
5560
(check-type spec spec)

lem-tests.asd

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
:components ((:file "mock-client")
3939
(:file "test-utils")
4040
(:file "tests")
41-
(:file "integration-tests")))
41+
(:file "integration-tests")
42+
(:file "spec-test")))
4243
#+sbcl
4344
(:module "mcp-server"
4445
:components ((:file "utils")

tests/lsp-mode/spec-test.lisp

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
(defpackage :lem-tests/lsp-mode/spec-test
2+
(:use :cl :rove)
3+
(:import-from :lem-lsp-mode/spec
4+
:get-language-spec
5+
:register-language-spec
6+
:spec)
7+
(:import-from :lem-lsp-mode
8+
:find-workspace))
9+
(in-package :lem-tests/lsp-mode/spec-test)
10+
11+
;;;; Regression tests for the no-spec / nil-language-id path that the
12+
;;;; lsp-mode hardening change made tolerant. Both functions are called
13+
;;;; transitively by lsp-mode hooks attached to every buffer, so any
14+
;;;; regression here crashes the editor command loop on the next keystroke
15+
;;;; in a buffer whose major mode has no language spec (e.g. REPL
16+
;;;; transcripts).
17+
18+
(deftest get-language-spec-returns-nil-for-unregistered-mode
19+
(testing "unknown mode -> nil (callers use when-let against this)"
20+
(let ((mode (gensym "UNREGISTERED-MODE-")))
21+
(ok (null (get-language-spec mode))
22+
"Unregistered modes must return nil, not signal an assertion")))
23+
24+
(testing "registered mode -> the spec instance"
25+
(let ((mode (gensym "REGISTERED-MODE-"))
26+
(spec (make-instance 'spec
27+
:mode 'dummy-mode
28+
:language-id "dummy"
29+
:connection-mode :stdio)))
30+
(register-language-spec mode spec)
31+
(ok (eq spec (get-language-spec mode))
32+
"A registered spec must round-trip through get-language-spec"))))
33+
34+
(deftest find-workspace-tolerates-nil-language-id
35+
;; Before the fix, find-workspace defaulted to :errorp t and signaled
36+
;; "The NIL workspace is not found." on the first keystroke in a
37+
;; buffer whose major mode had no spec. The :errorp nil path was
38+
;; already nil-safe; only the default-errorp case regressed.
39+
(testing "nil language-id with :errorp t -> nil (was an error)"
40+
(ok (null (find-workspace nil :errorp t))
41+
"A nil language-id means the buffer is not an LSP buffer, not a failure")))

0 commit comments

Comments
 (0)