diff --git a/CLAUDE.md b/CLAUDE.md index 2722f0f..fc20754 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,16 +53,17 @@ To add a new test: ### Communication Flow 1. Editor sends JSON-RPC requests via stdio 2. `read-lsp-message` (jsonrpc/messages.lisp) parses HTTP-like headers + JSON body -3. Requests dispatch to handlers registered in `*handlers*` hash table -4. Handlers access global state (`*documents*`, `*workspace-root*`, symbol tables) +3. Requests dispatch to handlers registered on the server context (`ctx:handlers`) +4. Handlers access shared state through `clef-context` accessors (`ctx:documents`, `ctx:workspace-root`, symbol tables, ...) 5. Responses convert to JSON-RPC and write to stdout ### Key Source Modules (src/) | Module | Purpose | |--------|---------| +| `context.lisp` | Central `server-context` struct + `*server*` — all persistent state lives here | | `jsonrpc/` | JSON-RPC protocol implementation | -| `lsp/server.lisp` | Main server loop, handler registration, state management | +| `lsp/server.lisp` | Main server loop, handler dispatch | | `lsp/lifecycle/` | Initialize/initialized/shutdown handlers | | `lsp/document/` | Document handlers (completion, definition, hover, formatting, diagnostics) | | `lsp/workspace/` | Workspace-level handlers | @@ -73,15 +74,32 @@ To add a new test: | `packages.lisp` | Package definitions and namespace exports | | `main.lisp` | Entry point (`clef-root:start-server`) | -### Global State Variables - -- `*documents*` - Hash table of open files (path → full text) -- `*handlers*` - Hash table mapping LSP methods to handler functions -- `*lexical-scopes-by-file*` - Maps files to interval trees of lexical scopes -- `*symbol-refs-by-file*` - Maps files to symbol references with location info -- `*workspace-root*` - Root directory of project -- `*client-capabilities*` - What the client editor supports -- `*initialized*` - Boolean for LSP lifecycle state +### Server Context (`clef-context`) + +All persistent server state lives on a single `server-context` struct held in +`clef-context:*server*`. Short symbol-macro aliases (`ctx:documents`, +`ctx:workspace-root`, `ctx:handlers`, ...) expand to struct-accessor reads on +`*server*`, so call sites read and write them as if they were ordinary +variables, including with `setf`. + +Fields on the context include: + +- `ctx:documents` — hash table of open files (URI → full text) +- `ctx:handlers` — hash table mapping LSP methods to handler functions +- `ctx:workspace-root` — project workspace root URI +- `ctx:client-capabilities` — client capabilities reported at initialize time +- `ctx:initialized` / `ctx:shutdown-received` — lifecycle flags +- `ctx:output-stream` — stream for outbound LSP notifications +- `ctx:lexical-scopes` / `ctx:symbol-refs` — per-file interval trees +- `ctx:workspace-symbol-index` — cross-file symbol lookup table +- `ctx:document-line-offsets` — per-file byte offset caches +- `ctx:global-scope` — root lexical-scope (builtins + external packages) +- `ctx:loaded-systems` / `ctx:file-to-system` / `ctx:asd-files` — ASDF state + +Shutdown and exit handlers call `ctx:reset-context` to atomically replace +`*server*` with a fresh context, which also gives tests a clean slate between +runs. No CLEF package should define its own mutable `defparameter` for +server state — put new fields on the struct in `src/context.lisp` instead. ### Symbol Resolution diff --git a/clef.asd b/clef.asd index f445445..7a41d07 100644 --- a/clef.asd +++ b/clef.asd @@ -19,6 +19,7 @@ :components ((:file "packages") (:file "util") (:file "log") + (:file "context") (:file "jsonrpc/types") (:file "jsonrpc/messages") (:file "parser/parser") diff --git a/src/context.lisp b/src/context.lisp new file mode 100644 index 0000000..d4f6fa8 --- /dev/null +++ b/src/context.lisp @@ -0,0 +1,132 @@ +(in-package :clef-context) + +;;; Central server context. +;;; +;;; Historically CLEF scattered its mutable state across many defparameters in +;;; several packages (clef-lsp/server, clef-symbols, clef-lsp/lifecycle). That +;;; made it hard to reset between test runs, impossible to host more than one +;;; server in a single image, and awkward when new handlers needed to reach +;;; across module boundaries to touch state. +;;; +;;; This file consolidates everything onto a single SERVER-CONTEXT struct held +;;; in the special variable *SERVER*. Each field corresponds to one of the old +;;; globals. Short symbol-macro aliases expand to field access on *SERVER*, +;;; so call sites look like ordinary variable references: +;;; +;;; (gethash uri (documents)) ; reads (server-context-documents *server*) +;;; (setf (workspace-root) path) ; writes (server-context-workspace-root *server*) +;;; +;;; Tests and shutdown handlers can swap *SERVER* with a fresh context to reset +;;; all state atomically. + +(defstruct server-context + "All persistent state for a running CLEF LSP server. + +Fields are grouped by subsystem: + +Lifecycle: + initialized -- set once the client sends the `initialized' notification + shutdown-received -- set when the client sends `shutdown'; used by `exit' + client-capabilities -- hash table of capabilities from `initialize' + workspace-root -- URI of the project root + output-stream -- stream for outbound LSP messages (notifications) + +Handlers & documents: + handlers -- method-name -> handler-function + documents -- document URI -> full text string + +Symbol analysis (formerly in clef-symbols): + lexical-scopes -- file path -> interval tree of lexical-scope's + symbol-refs -- file path -> interval tree of symbol-reference's + workspace-symbol-index -- symbol-name -> list of symbol-definitions (cross-file) + document-line-offsets -- file path -> vector of per-line byte offsets + global-scope -- root lexical-scope holding builtins + externals + +ASDF systems (formerly in clef-lsp/lifecycle): + loaded-systems -- system name -> system-info + file-to-system -- absolute file path -> owning system name + asd-files -- list of all discovered .asd pathnames" + (initialized nil :type boolean) + (shutdown-received nil :type boolean) + (client-capabilities nil) + (workspace-root nil) + (output-stream nil) + (handlers (make-hash-table :test 'equal)) + (documents (make-hash-table :test 'equal)) + (lexical-scopes (make-hash-table :test 'equal)) + (symbol-refs (make-hash-table :test 'equal)) + (workspace-symbol-index (make-hash-table :test 'equal)) + (document-line-offsets (make-hash-table :test 'equal)) + (global-scope nil) + (loaded-systems (make-hash-table :test 'equal)) + (file-to-system (make-hash-table :test 'equal)) + (asd-files nil)) + +(defparameter *server* (make-server-context) + "The current CLEF LSP server context. + +Bound to a freshly constructed SERVER-CONTEXT at image load, replaced on +RESET-CONTEXT, and rebindable with LET for isolated tests.") + +(defun reset-context () + "Replace *SERVER* with a fresh context, discarding all server state. +Called from shutdown and exit handlers and from test setup." + (setf *server* (make-server-context))) + +;;; Symbol-macro aliases. +;;; +;;; These let callers write `(documents)' or `(setf (workspace-root) x)' as +;;; if they were plain functions/places, without the noise of threading +;;; *server* through every call. Setf works transparently because each +;;; expansion bottoms out in a struct accessor, which has a real setf +;;; expander. + +(defmacro define-context-accessor (short-name field-accessor docstring) + "Define SHORT-NAME as a symbol-macro that reads/writes (FIELD-ACCESSOR *SERVER*)." + (declare (ignore docstring)) + `(define-symbol-macro ,short-name (,field-accessor *server*))) + +(define-context-accessor initialized server-context-initialized + "Whether the client has confirmed initialization.") + +(define-context-accessor shutdown-received server-context-shutdown-received + "Whether shutdown has been requested.") + +(define-context-accessor client-capabilities server-context-client-capabilities + "Client capabilities reported at initialize time.") + +(define-context-accessor workspace-root server-context-workspace-root + "Project workspace root URI.") + +(define-context-accessor output-stream server-context-output-stream + "Outbound LSP message stream.") + +(define-context-accessor handlers server-context-handlers + "Method name -> handler function.") + +(define-context-accessor documents server-context-documents + "Document URI -> current text.") + +(define-context-accessor lexical-scopes server-context-lexical-scopes + "File path -> interval tree of lexical scopes.") + +(define-context-accessor symbol-refs server-context-symbol-refs + "File path -> interval tree of symbol references.") + +(define-context-accessor workspace-symbol-index server-context-workspace-symbol-index + "Symbol name -> list of symbol-definitions, across all files.") + +(define-context-accessor document-line-offsets server-context-document-line-offsets + "File path -> vector of byte offsets for each line.") + +(define-context-accessor global-scope server-context-global-scope + "Root lexical-scope for the workspace (holds builtins + externals).") + +(define-context-accessor loaded-systems server-context-loaded-systems + "System name -> system-info for ASDF systems discovered in the workspace.") + +(define-context-accessor file-to-system server-context-file-to-system + "Absolute file path -> system name that owns it.") + +(define-context-accessor asd-files server-context-asd-files + "List of every .asd file discovered in the workspace.") diff --git a/src/lsp/document/diagnostic.lisp b/src/lsp/document/diagnostic.lisp index fd827bb..51b06d3 100644 --- a/src/lsp/document/diagnostic.lisp +++ b/src/lsp/document/diagnostic.lisp @@ -58,7 +58,7 @@ (let* ((document-uri (href (clef-jsonrpc/types:request-params message) "text-document" "uri")) - (document-text (gethash document-uri clef-lsp/server:*documents*)) + (document-text (gethash document-uri ctx:documents)) (syntax-errors (get-syntax-errors document-text)) (compile-errors (debounced-sb-collect-diagnostics document-text document-uri)) (items (append syntax-errors compile-errors))) diff --git a/src/lsp/document/did-change.lisp b/src/lsp/document/did-change.lisp index 635cc69..9b25b29 100644 --- a/src/lsp/document/did-change.lisp +++ b/src/lsp/document/did-change.lisp @@ -31,21 +31,21 @@ ;; Unpack the params into the document uri and range/text data (let* ((params (clef-jsonrpc/types:request-params message)) (document-uri (href params "text-document" "uri")) - (content-changes (href params "content-changes"))) + (content-changes (href params "content-changes")) + (documents ctx:documents)) (slog :debug "[textDocument/didChange] Document: ~A" document-uri) - ;; (slog :debug "[textDocument/didChange] File found: ~A" (nth-value 1 (gethash document-uri clef-lsp/server:*documents*))) (dotimes (i (length content-changes)) (let* ((content-change (aref content-changes i)) (new-document-text (href content-change "text"))) - (setf (gethash document-uri clef-lsp/server:*documents*) new-document-text))) + (setf (gethash document-uri documents) new-document-text))) ;; Reprocess the symbol-map. This is terribly jank and inefficient to do on every single change; needs debounced at the very least (slog :debug "[textDocument/didChange] Rebuilding symbol map for document: ~A..." document-uri) (let ((start-time (get-internal-real-time))) (clef-symbols:build-file-symbol-map (clef-util:cleanup-path document-uri) - (gethash document-uri clef-lsp/server:*documents*)) + (gethash document-uri documents)) (slog :debug "[textDocument/didChange] Rebuilt symbol map in ~A ms." (/ (- (get-internal-real-time) start-time) 1000.0))))) diff --git a/src/lsp/document/did-open.lisp b/src/lsp/document/did-open.lisp index 88635a3..ae32faa 100644 --- a/src/lsp/document/did-open.lisp +++ b/src/lsp/document/did-open.lisp @@ -4,6 +4,4 @@ (let* ((params-hash (clef-jsonrpc/types:request-params message)) (document-uri (href params-hash "text-document" "uri")) (document-text (href params-hash "text-document" "text"))) - - ;; (slog :debug "opened text: ~A" document-text) - (setf (gethash document-uri clef-lsp/server:*documents*) document-text))) + (setf (gethash document-uri ctx:documents) document-text))) diff --git a/src/lsp/document/did-save.lisp b/src/lsp/document/did-save.lisp index 04935fa..b113a9d 100644 --- a/src/lsp/document/did-save.lisp +++ b/src/lsp/document/did-save.lisp @@ -9,19 +9,19 @@ (reload-asd-file document-uri)) ;; Rebuild symbol map for the saved file (let ((document-text (gethash (format nil "file://~A" document-uri) - clef-lsp/server:*documents*))) + ctx:documents))) (when document-text (clef-symbols:build-file-symbol-map document-uri document-text))))) (defun reload-asd-file (asd-path) "Re-parse an .asd file and reload any changed systems." (slog :debug "Reloading .asd file: ~A" asd-path) - (let ((new-systems (clef-lsp/lifecycle::parse-asd-file asd-path))) + (let ((new-systems (clef-lsp/lifecycle:parse-asd-file asd-path))) (dolist (sys new-systems) (let ((name (clef-symbols:system-info-name sys))) ;; Update or add the system info - (setf (gethash name clef-lsp/lifecycle::*loaded-systems*) sys) + (setf (gethash name ctx:loaded-systems) sys) ;; Reload the system - (clef-lsp/lifecycle::load-system-with-info sys))) + (clef-lsp/lifecycle:load-system-with-info sys))) ;; Rebuild file mapping - (clef-lsp/lifecycle::build-file-to-system-mapping))) + (clef-lsp/lifecycle:build-file-to-system-mapping))) diff --git a/src/lsp/document/formatting.lisp b/src/lsp/document/formatting.lisp index 643b1b9..b32000a 100644 --- a/src/lsp/document/formatting.lisp +++ b/src/lsp/document/formatting.lisp @@ -17,7 +17,7 @@ (defun handle-text-document-formatting (message) (let* ((params (clef-jsonrpc/types:request-params message)) (file-uri (href params "text-document" "uri")) - (document-text (gethash file-uri clef-lsp/server:*documents*)) + (document-text (gethash file-uri ctx:documents)) (pos (href params "position")) (options (href params "options"))) ;; Format the entire file with cl-indentify diff --git a/src/lsp/document/highlight.lisp b/src/lsp/document/highlight.lisp index a98686d..6e982fb 100644 --- a/src/lsp/document/highlight.lisp +++ b/src/lsp/document/highlight.lisp @@ -38,7 +38,7 @@ Returns all occurrences of the symbol under cursor in the current document." ;; Find all occurrences in this file (let ((highlights '())) ;; Add all references in this file - (let ((refs-tree (gethash file-path clef-symbols:*symbol-refs-by-file*))) + (let ((refs-tree (gethash file-path ctx:symbol-refs))) (when refs-tree (let ((all-refs (get-all-intervals-from-tree refs-tree))) (dolist (interval all-refs) @@ -51,7 +51,7 @@ Returns all occurrences of the symbol under cursor in the current document." highlights))))))) ;; Add definitions in this file - (let ((scopes-tree (gethash file-path clef-symbols:*lexical-scopes-by-file*))) + (let ((scopes-tree (gethash file-path ctx:lexical-scopes))) (when scopes-tree (let ((all-scopes (get-all-intervals-from-tree scopes-tree))) (dolist (scope-interval all-scopes) @@ -72,7 +72,3 @@ Returns all occurrences of the symbol under cursor in the current document." "Create an LSP DocumentHighlight dict from a tree-sitter node." (dict "range" (node-to-lsp-range node) "kind" kind)) - -;; Register the handler -(setf (gethash "textDocument/documentHighlight" clef-lsp/server:*handlers*) - #'handle-text-document-highlight) diff --git a/src/lsp/document/hover.lisp b/src/lsp/document/hover.lisp index bafb885..a09308c 100644 --- a/src/lsp/document/hover.lisp +++ b/src/lsp/document/hover.lisp @@ -46,10 +46,10 @@ (hover-line (href params "position" "line")) (hover-char (href params "position" "character")) (symbol-at-pos (find-symbol-at-position - (href clef-lsp/server:*documents* document-uri) + (gethash document-uri ctx:documents) hover-line hover-char)) - (document-text (href clef-lsp/server:*documents* document-uri)) + (document-text (gethash document-uri ctx:documents)) (tree (clef-parser/parser:parse-string document-text)) (symbol-pkg (or (clef-parser/utils:find-package-declaration tree document-text) *package*)) diff --git a/src/lsp/document/references.lisp b/src/lsp/document/references.lisp index 0cf9284..42d8b2e 100644 --- a/src/lsp/document/references.lisp +++ b/src/lsp/document/references.lisp @@ -55,10 +55,10 @@ Returns all locations where the symbol at the given position is referenced." This handles the case where the cursor is on a function/variable name in a definition (e.g., the 'foo' in '(defun foo ...)'), rather than on a usage." (let* ((file-path (clef-util:cleanup-path document-uri)) - (offset (clef-symbols::line-char-to-byte-offset file-path line character))) + (offset (clef-symbols:line-char-to-byte-offset file-path line character))) ;; Get the lexical scope at this position (let ((scopes (interval:find-all - (gethash file-path clef-symbols:*lexical-scopes-by-file*) + (gethash file-path ctx:lexical-scopes) offset))) ;; Check each scope (from innermost to outermost) for definitions at this position (dolist (scope-interval scopes) @@ -100,7 +100,7 @@ Returns a list of LSP Location dicts." ;; Walk all intervals in the tree to find matching symbol names (let ((file-refs (find-refs-in-tree refs-tree symbol-name file-path))) (setf locations (nconc locations file-refs))))) - clef-symbols:*symbol-refs-by-file*) + ctx:symbol-refs) locations)) (defun find-refs-in-tree (refs-tree symbol-name file-path) diff --git a/src/lsp/document/signature-help.lisp b/src/lsp/document/signature-help.lisp index 6b52e2e..1851551 100644 --- a/src/lsp/document/signature-help.lisp +++ b/src/lsp/document/signature-help.lisp @@ -8,7 +8,7 @@ Returns signature information for the function call at the cursor position." (position (href params "position")) (line (href position "line")) (character (href position "character")) - (document-text (gethash document-uri clef-lsp/server:*documents*))) + (document-text (gethash document-uri ctx:documents))) (slog :debug "[textDocument/signatureHelp] Document: ~A" document-uri) (slog :debug "[textDocument/signatureHelp] Position: line ~A, char ~A" line character) @@ -214,7 +214,3 @@ Tries workspace index first, then falls back to sb-introspect for loaded functio (dict "signatures" (vector signature) "activeSignature" 0 "activeParameter" (or active-param 0)))) - -;; Register the handler -(setf (gethash "textDocument/signatureHelp" clef-lsp/server:*handlers*) - #'handle-text-document-signature-help) diff --git a/src/lsp/lifecycle/initialize.lisp b/src/lsp/lifecycle/initialize.lisp index 9501f57..3915ad4 100644 --- a/src/lsp/lifecycle/initialize.lisp +++ b/src/lsp/lifecycle/initialize.lisp @@ -1,15 +1,11 @@ (in-package :clef-lsp/lifecycle) -;;; State for tracking multiple ASDF systems in the workspace - -(defparameter *loaded-systems* (make-hash-table :test 'equal) - "Hash table mapping system names (strings) to system-info structs.") - -(defparameter *file-to-system* (make-hash-table :test 'equal) - "Hash table mapping absolute file paths to the system name they belong to.") - -(defparameter *asd-files* nil - "List of all discovered .asd file paths in the workspace.") +;;; Workspace ASDF system discovery and loading. +;;; +;;; The loaded-systems / file-to-system / asd-files tables formerly defined +;;; here as defparameters now live on CLEF-CONTEXT:*SERVER* so that all +;;; CLEF state resets atomically on shutdown. This file just reads and +;;; writes them through the CTX: aliases. ;; TODO: I need to move all of the asd/system loading into a thread for resiliency @@ -246,10 +242,11 @@ (defun compute-system-load-order () "Compute topological order for loading systems based on inter-project dependencies. Systems with no local dependencies are loaded first." - (let ((local-system-names (loop for name being the hash-keys of *loaded-systems* - collect name)) - (no-local-deps '()) - (with-local-deps '())) + (let* ((systems ctx:loaded-systems) + (local-system-names (loop for name being the hash-keys of systems + collect name)) + (no-local-deps '()) + (with-local-deps '())) ;; Partition systems by whether they have local dependencies (maphash (lambda (name sys-info) (let* ((deps (clef-symbols:system-info-dependencies sys-info)) @@ -262,7 +259,7 @@ Systems with no local dependencies are loaded first." (if (null local-deps) (push name no-local-deps) (push name with-local-deps)))) - *loaded-systems*) + systems) ;; Return systems without local deps first, then those with deps (append (nreverse no-local-deps) (nreverse with-local-deps)))) @@ -295,12 +292,13 @@ Systems with no local dependencies are loaded first." (slog :warn "Failed to load system ~A: ~A" system-name e))))) (defun build-file-to-system-mapping () - "Populate *file-to-system* from loaded system info." - (clrhash *file-to-system*) - (maphash (lambda (system-name sys-info) - (dolist (file-path (clef-symbols:system-info-source-files sys-info)) - (setf (gethash file-path *file-to-system*) system-name))) - *loaded-systems*)) + "Populate the file-to-system table on *SERVER* from loaded system info." + (let ((mapping ctx:file-to-system)) + (clrhash mapping) + (maphash (lambda (system-name sys-info) + (dolist (file-path (clef-symbols:system-info-source-files sys-info)) + (setf (gethash file-path mapping) system-name))) + ctx:loaded-systems))) (defun load-all-workspace-systems (root-uri) "Discover and load all ASDF systems in the workspace." @@ -308,12 +306,12 @@ Systems with no local dependencies are loaded first." (if (null asd-files) (slog :warn "No .asd files found in workspace: ~A" root-uri) (progn - (setf *asd-files* asd-files) + (setf ctx:asd-files asd-files) (slog :info "Discovered ~A .asd file(s) in workspace" (length asd-files)) ;; Clear previous state - (clrhash *loaded-systems*) - (clrhash *file-to-system*) + (clrhash ctx:loaded-systems) + (clrhash ctx:file-to-system) ;; Phase 1: Parse all .asd files to discover systems (dolist (asd-path asd-files) @@ -321,7 +319,7 @@ Systems with no local dependencies are loaded first." (let ((systems (parse-asd-file asd-path))) (dolist (sys systems) (slog :debug "Found system: ~A" (clef-symbols:system-info-name sys)) - (setf (gethash (clef-symbols:system-info-name sys) *loaded-systems*) sys)))) + (setf (gethash (clef-symbols:system-info-name sys) ctx:loaded-systems) sys)))) ;; Phase 2: Determine load order based on dependencies (let ((load-order (compute-system-load-order))) @@ -329,25 +327,25 @@ Systems with no local dependencies are loaded first." ;; Phase 3: Load each system in dependency order (dolist (system-name load-order) - (let ((sys-info (gethash system-name *loaded-systems*))) + (let ((sys-info (gethash system-name ctx:loaded-systems))) (when sys-info (load-system-with-info sys-info))))) ;; Phase 4: Build file-to-system mapping (build-file-to-system-mapping) (slog :info "Loaded ~A system(s), mapped ~A file(s)" - (hash-table-count *loaded-systems*) - (hash-table-count *file-to-system*)))))) + (hash-table-count ctx:loaded-systems) + (hash-table-count ctx:file-to-system)))))) ;;; Utility functions for querying system state (defun get-file-system (file-path) "Get the system name that a file belongs to, or nil if unknown." - (gethash (namestring file-path) *file-to-system*)) + (gethash (namestring file-path) ctx:file-to-system)) (defun list-workspace-systems () "Return a list of all discovered system names." - (loop for name being the hash-keys of *loaded-systems* + (loop for name being the hash-keys of ctx:loaded-systems collect name)) (defun handle-initialize (request) @@ -359,7 +357,7 @@ Systems with no local dependencies are loaded first." ;; We currently assume one does exist and it's the first value (let ((workspace-root (href (aref (href params-hash "workspace-folders") 0) "uri"))) (slog :info "Client workspace root: ~A" workspace-root) - (setf clef-lsp/server:*workspace-root* workspace-root) + (setf ctx:workspace-root workspace-root) ;; Load all .asd files (root + test directories) (load-all-workspace-systems workspace-root) (let ((start-time (get-internal-real-time))) @@ -374,8 +372,7 @@ Systems with no local dependencies are loaded first." ;; behavior, and notify the client. (slog :error "Failed to get client workspace root: ~A" e))) - - (setf clef-lsp/server:*client-capabilities* capabilities) + (setf ctx:client-capabilities capabilities) ;; TODO: use *server-capabilities* ;; https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initializeResult diff --git a/src/lsp/lifecycle/initialized.lisp b/src/lsp/lifecycle/initialized.lisp index ef57a36..a3e67e8 100644 --- a/src/lsp/lifecycle/initialized.lisp +++ b/src/lsp/lifecycle/initialized.lisp @@ -2,6 +2,6 @@ (defun handle-initialized (request) (declare (ignore request)) - (setf clef-lsp/server:*initialized* t) + (setf ctx:initialized t) ;; Send no response nil) diff --git a/src/lsp/server.lisp b/src/lsp/server.lisp index 45b0cfc..86bc5b7 100644 --- a/src/lsp/server.lisp +++ b/src/lsp/server.lisp @@ -1,22 +1,11 @@ (in-package :clef-lsp/server) -(defparameter *initialized* nil - "Whether the client has responded with initialized. The server only responds -with ServerNotInitialized = -32002 before this occurs.") - -(defparameter *handlers* (make-hash-table :test 'equal) - "A hash table mapping LSP endpoint names to their handler functions.") - -(defparameter *client-capabilities* nil - "Stores a hash table of the client's capabilities as reported during initialization. -Seehttps://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#clientCapabilities ") - -(defvar *documents* (make-hash-table :test 'equal) - "Document objects kept in memory and maintained by textDocument/didOpen, didChange, didClose requests. -Top level keys are file paths/URIs, values are the full text of the documents as strings. Newline chars. are preserved with -(currently) no consideration of cross-platform differences.") - -(defvar *workspace-root* nil "The path to the root of the project workspace. Set in the initialize handler.") +;;; LSP server loop and handler dispatch. +;;; +;;; All persistent state lives on the CLEF-CONTEXT:SERVER-CONTEXT struct held +;;; in CLEF-CONTEXT:*SERVER*. This file used to own several defparameters +;;; (*initialized*, *documents*, *workspace-root*, ...) that have been moved +;;; there; see src/context.lisp for the canonical definitions. (defun before-handle-request (request) "Hook to run before handling any request." @@ -24,7 +13,7 @@ Top level keys are file paths/URIs, values are the full text of the documents as ;; Error if server not initialized, unless these are requests to the endpoints that handle initialization (when (and (not (string= endpoint-name "initialize")) (not (string= endpoint-name "initialized")) - (not *initialized*)) + (not ctx:initialized)) (slog :error "Server not initialized yet.") (error 'clef-lsp/types/base:server-not-initialized-error)))) @@ -41,7 +30,7 @@ Top level keys are file paths/URIs, values are the full text of the documents as (declare (ignore e)) (setf captured-backtrace (capture-backtrace))))) (let* ((endpoint-name (clef-jsonrpc/types:request-method request))) - (let ((handler (gethash endpoint-name *handlers*))) + (let ((handler (gethash endpoint-name ctx:handlers))) (if handler (let ((message (funcall handler request))) (if (null message) @@ -71,6 +60,7 @@ Top level keys are file paths/URIs, values are the full text of the documents as (defun run-lsp-server-stdio (&key (input *standard-input*) (output *standard-output*)) "Run LSP server over stdio" + (setf ctx:output-stream output) (loop (let ((request (clef-jsonrpc/messages:read-lsp-message input))) (when request @@ -80,21 +70,32 @@ Top level keys are file paths/URIs, values are the full text of the documents as (when response (clef-jsonrpc/messages:write-lsp-message response output))))))) +(defun send-notification (method params) + "Send an LSP notification (a message with no id that doesn't expect a response)." + (let ((stream ctx:output-stream)) + (when stream + (let ((notification (serapeum:dict + "jsonrpc" "2.0" + "method" method + "params" params))) + (clef-jsonrpc/messages:write-lsp-message notification stream))))) + +(defun publish-diagnostics (uri diagnostics) + "Publish diagnostics for a document using textDocument/publishDiagnostics notification." + (send-notification "textDocument/publishDiagnostics" + (serapeum:dict "uri" uri + "diagnostics" (or diagnostics #())))) + (defun sethandler (endpoint-name handler-lambda) "Defines an LSP handler for the given endpoint name." (slog :debug "Defining LSP handler for endpoint: ~A" endpoint-name) - (setf (gethash endpoint-name *handlers*) + (setf (gethash endpoint-name ctx:handlers) (lambda (request) - ;; From src/lsp/server.lisp (before-handle-request request) - ;; (slog :debug "[~A] →" endpoint-name) (funcall handler-lambda request)))) -;; TODO: It'd be cool to make a macro for registering handlers and not requiring exporting them + doing -;; setup there... but probably no real point. (defun register-handlers () - "Registers all LSP handlers from *handlers*" - ;; For now, just a bunch of manual sethandler calls. Need to reconsider this later + "Registers all LSP handlers on the current context." (sethandler "initialize" 'clef-lsp/lifecycle:handle-initialize) (sethandler "initialized" 'clef-lsp/lifecycle:handle-initialized) (sethandler "textDocument/completion" 'clef-lsp/document:handle-text-document-completion) @@ -106,16 +107,17 @@ Top level keys are file paths/URIs, values are the full text of the documents as (sethandler "textDocument/formatting" 'clef-lsp/document:handle-text-document-formatting) (sethandler "textDocument/diagnostic" 'clef-lsp/document:handle-text-document-diagnostic) (sethandler "textDocument/hover" 'clef-lsp/document:handle-text-document-hover) + (sethandler "textDocument/documentHighlight" 'clef-lsp/document:handle-text-document-highlight) + (sethandler "textDocument/signatureHelp" 'clef-lsp/document:handle-text-document-signature-help) (sethandler "workspace/diagnostic" 'clef-lsp/workspace:handle-workspace-diagnostic) (sethandler "workspace/didChangeConfiguration" 'clef-lsp/workspace:handle-workspace-did-change-configuration) + (sethandler "workspace/symbol" 'clef-lsp/workspace:handle-workspace-symbol) (sethandler "shutdown" 'clef-lsp/misc:handle-shutdown) (sethandler "exit" 'clef-lsp/misc:handle-exit)) (defun reset () - "Resets all server state, to be called when the server is asked to shutdown or exit" - (setf *initialized* nil) - (setf *client-capabilities* nil) - (setf *documents* (make-hash-table :test 'equal)) + "Discard all server state by installing a fresh context." + (ctx:reset-context) (slog :info "CLEF LSP server state has been reset.")) (defun start (&key (input *standard-input*) (output *standard-output*) (log-mode :file)) @@ -124,8 +126,6 @@ Top level keys are file paths/URIs, values are the full text of the documents as ;; Controls verbosity and whether to output logs to console or a file (clef-log:init log-mode) - ;; TODO: Spawn the server in a new thread, watch for crashes, and restart if that occurs. - ;; Also listen for & handle LSP messages to shut down / restart the server (slog :debug "Starting CLEF LSP server...") (slog :debug "Registering handlers...") (register-handlers) diff --git a/src/lsp/workspace/symbol.lisp b/src/lsp/workspace/symbol.lisp index 4fb295d..bcc8b86 100644 --- a/src/lsp/workspace/symbol.lisp +++ b/src/lsp/workspace/symbol.lisp @@ -44,7 +44,7 @@ Returns symbols matching the query from across the workspace." (search query-upcase (string-upcase symbol-name))) (dolist (def defs) (push (symbol-def-to-symbol-info def symbol-name) results)))) - clef-symbols:*workspace-symbol-index*) + ctx:workspace-symbol-index) (slog :debug "[workspace/symbol] Found ~A matching symbols" (length results)) @@ -86,7 +86,3 @@ Returns symbols matching the query from across the workspace." "character" (clef-parser/parser:node-start-point-column node)) "end" (dict "line" (clef-parser/parser:node-end-point-row node) "character" (clef-parser/parser:node-end-point-column node))))) - -;; Register the handler -(setf (gethash "workspace/symbol" clef-lsp/server:*handlers*) - #'handle-workspace-symbol) diff --git a/src/packages.lisp b/src/packages.lisp index 1249b3e..c32151d 100644 --- a/src/packages.lisp +++ b/src/packages.lisp @@ -14,6 +14,51 @@ *log-file-path* init)) +(defpackage :clef-context + (:use :cl) + (:documentation "Central server context. Holds all persistent CLEF LSP +state on a single SERVER-CONTEXT struct bound to *SERVER*. All other CLEF +packages reach shared state through the accessors exported here rather than +through their own defparameters.") + (:export :server-context + :make-server-context + :server-context-p + :*server* + :reset-context + ;; Struct field accessors (the generated ones, for when the + ;; symbol-macro aliases can't be used) + :server-context-initialized + :server-context-shutdown-received + :server-context-client-capabilities + :server-context-workspace-root + :server-context-output-stream + :server-context-handlers + :server-context-documents + :server-context-lexical-scopes + :server-context-symbol-refs + :server-context-workspace-symbol-index + :server-context-document-line-offsets + :server-context-global-scope + :server-context-loaded-systems + :server-context-file-to-system + :server-context-asd-files + ;; Symbol-macro aliases (short form, preferred at call sites) + :initialized + :shutdown-received + :client-capabilities + :workspace-root + :output-stream + :handlers + :documents + :lexical-scopes + :symbol-refs + :workspace-symbol-index + :document-line-offsets + :global-scope + :loaded-systems + :file-to-system + :asd-files)) + (defpackage :clef-root (:use :cl :clef-log) (:export :start-server)) @@ -66,12 +111,11 @@ (defpackage :clef-symbols (:use :cl :clef-log :clef-parser/parser) (:local-nicknames + (:ctx :clef-context) (:ts :cl-tree-sitter/high-level) (:ts-ll :cl-tree-sitter/low-level)) (:export build-project-symbol-map build-file-symbol-map - *lexical-scopes-by-file* - *symbol-refs-by-file* get-ref-for-doc-pos lexical-scope-kind lexical-scope-symbol-definitions @@ -96,31 +140,28 @@ system-info-dependencies system-info-source-files system-info-loaded-p - ;; workspace symbol index for cross-file go-to-definition - *workspace-symbol-index* + ;; Workspace symbol index management (operates on context) clear-workspace-symbol-index remove-file-from-workspace-index add-to-workspace-index - lookup-in-workspace-index)) + lookup-in-workspace-index + ;; Byte offset helpers (used by some handlers) + line-char-to-byte-offset)) (defpackage :clef-lsp/server (:use :cl :clef-log) + (:local-nicknames + (:ctx :clef-context)) (:import-from :serapeum :dict) (:export :start - *handlers* - *initialized* - *documents* - *client-capabilities* - *server-capabilities-json* - *workspace-root* + :sethandler + :register-handlers :before-handle-request - *server* - :reset)) - -;; (defpackage :clef-lsp/defhandler -;; (:use :cl :clef-log) -;; (:import-from :clef-lsp/server *handlers* :before-handle-request) -;; (:export :defhandler)) + :handle-lsp-request + :send-notification + :publish-diagnostics + :reset + *server-capabilities-json*)) (defpackage :clef-lsp/types/base (:use :cl :clef-log) @@ -169,17 +210,10 @@ :position-line :position-character)) -;; (defpackage :clef-lsp/types/lifecycle -;; (:use :cl :clef-lsp/types/base :schemata) -;; (:export :initialize-params -;; :initialize-params-process-id -;; :initialize-params-root-uri -;; :initialize-params-capabilities -;; :workspace-folder -;; :client-capabilities)) - (defpackage :clef-lsp/lifecycle (:use :cl :clef-log) + (:local-nicknames + (:ctx :clef-context)) (:import-from :serapeum :dict :href) (:export handle-initialize handle-initialized @@ -187,19 +221,20 @@ load-workspace-asd load-asd ;; Multi-ASD support - *loaded-systems* - *file-to-system* - *asd-files* discover-asd-files load-all-workspace-systems get-file-system - list-workspace-systems)) + list-workspace-systems + parse-asd-file + load-system-with-info + build-file-to-system-mapping)) (defpackage :clef-lsp/document (:use :cl :clef-log :clef-symbols) - (:import-from :serapeum :dict :href) (:local-nicknames + (:ctx :clef-context) (:ts :cl-tree-sitter/high-level)) + (:import-from :serapeum :dict :href) (:export handle-text-document-completion handle-text-document-definition @@ -215,6 +250,8 @@ (defpackage :clef-lsp/workspace (:use :cl :clef-log) + (:local-nicknames + (:ctx :clef-context)) (:import-from :serapeum :dict :href) (:export handle-workspace-diagnostic handle-workspace-did-change-configuration @@ -222,6 +259,8 @@ (defpackage :clef-lsp/misc (:use :cl :clef-log) + (:local-nicknames + (:ctx :clef-context)) (:import-from :serapeum :dict) (:export handle-shutdown handle-exit)) diff --git a/src/symbols/init.lisp b/src/symbols/init.lisp index 997b00b..af1c1dd 100644 --- a/src/symbols/init.lisp +++ b/src/symbols/init.lisp @@ -1,57 +1,49 @@ (in-package :clef-symbols) -(defparameter *lexical-scopes-by-file* (make-hash-table) - "A hash-table mapping file paths to interval trees of lexical-scope's") - -(defparameter *symbol-refs-by-file* (make-hash-table) - "A hash-table mapping file paths to interval trees of symbol-reference's") +;;; Symbol analysis. +;;; +;;; Persistent state (scope trees, symbol-ref trees, the workspace-wide symbol +;;; index, per-file line offset caches, the global scope) all live on +;;; CLEF-CONTEXT:*SERVER*. Transient state used while walking a parse tree -- +;;; the current scope and current package -- remain as dynamic specials in +;;; this package; they are only meaningful inside a single +;;; BUILD-FILE-SYMBOL-MAP call and don't belong in the shared context. (defparameter *current-scope* nil "The current lexical scope that is the context in which the current processed node is occurring") -;; TODO: This should be universal to the LSP and not specific to this package -(defparameter *document-line-lengths* (make-hash-table) - "A hash-table mapping file paths to vectors of line lengths for that document.") -(defparameter *document-line-offsets* (make-hash-table) - "A hash-table mapping file paths to vectors of line offsets (starting byte offset) for that document.") - (defparameter *current-package* nil "The name of the current package encountered when processing the file") -(defparameter *global-scope* nil - "The global lexical scope for the entire workspace. Should the root of every scope tree") - -(defparameter *workspace-symbol-index* (make-hash-table :test 'equal) - "Hash table mapping symbol names (strings) to lists of symbol-definition's across all files. -Used for cross-file go-to-definition.") - ;;; Workspace symbol index management (defun clear-workspace-symbol-index () "Clear the entire workspace symbol index." - (clrhash *workspace-symbol-index*)) + (clrhash ctx:workspace-symbol-index)) (defun remove-file-from-workspace-index (file-path) "Remove all symbol definitions from FILE-PATH from the workspace index." - (maphash (lambda (symbol-name defs) - (let ((filtered (remove-if (lambda (def) - (let ((loc (symbol-definition-location def))) - (and loc (string= (location-file-path loc) file-path)))) - defs))) - (if filtered - (setf (gethash symbol-name *workspace-symbol-index*) filtered) - (remhash symbol-name *workspace-symbol-index*)))) - *workspace-symbol-index*)) + (let ((index ctx:workspace-symbol-index)) + (maphash (lambda (symbol-name defs) + (let ((filtered (remove-if (lambda (def) + (let ((loc (symbol-definition-location def))) + (and loc (string= (location-file-path loc) file-path)))) + defs))) + (if filtered + (setf (gethash symbol-name index) filtered) + (remhash symbol-name index)))) + index))) (defun add-to-workspace-index (symbol-def) "Add a symbol definition to the workspace index for cross-file lookup." (let* ((name (symbol-definition-symbol-name symbol-def)) - (existing (gethash name *workspace-symbol-index*))) - (setf (gethash name *workspace-symbol-index*) + (index ctx:workspace-symbol-index) + (existing (gethash name index))) + (setf (gethash name index) (cons symbol-def existing)))) (defun lookup-in-workspace-index (symbol-name) "Look up a symbol by name in the workspace index. Returns a list of matching definitions." - (gethash symbol-name *workspace-symbol-index*)) + (gethash symbol-name ctx:workspace-symbol-index)) ;; No longer needed since we set these on the global scope directly ;; (defparameter *built-in-symbol-defs* nil @@ -66,9 +58,9 @@ Note that symbol-ref can be nil if none is at the location" ;; (slog :debug ">>>>>>>>: ~A ~A ~A" file-path line char) (let* ((path (clef-util:cleanup-path file-path)) (offset (line-char-to-byte-offset path line char)) - (symbol-refs (interval:find-all (gethash path *symbol-refs-by-file*) offset)) + (symbol-refs (interval:find-all (gethash path ctx:symbol-refs) offset)) ;; Also get the lexical scope by position, as symbol-refs may be nil - (scopes (interval:find-all (gethash path *lexical-scopes-by-file*) offset))) + (scopes (interval:find-all (gethash path ctx:lexical-scopes) offset))) ;; (slog :debug "Found symbol-defs at line ~A char ~A (offset ~A): ~A" line char offset symbol-defs) ;; (slog :debug ">>>> scope intervals found: ~A" scopes) ;; (values nil nil))) @@ -88,7 +80,7 @@ Note that symbol-ref can be nil if none is at the location" ;; the nodes the high-level API creates (defun line-char-to-byte-offset (file-path line char) "Converts a line and character position to a byte offset." - (let* ((line-offsets (gethash file-path *document-line-offsets*)) + (let* ((line-offsets (gethash file-path ctx:document-line-offsets)) (line-index line) ;; Convert to 0-based (char-index char)) ;; Already 0-based ;; Add the char offset to the pre-calculated line offset @@ -102,7 +94,7 @@ Note that symbol-ref can be nil if none is at the location" ;; Long-term we need to either utility-ize this kind of text seeking, or find a different way to use tree-sitter that actually exposes ;; the byte offsets directly. (defun fast-node-text (node source file-path) - (let* ((line-offsets (gethash file-path *document-line-offsets*)) + (let* ((line-offsets (gethash file-path ctx:document-line-offsets)) (start-row (node-start-point-row node)) (start-col (node-start-point-column node)) (end-row (node-end-point-row node)) @@ -168,18 +160,18 @@ Note that symbol-ref can be nil if none is at the location" (clear-workspace-symbol-index) ;; Init the global scope and load in builtins + externals - (setf *global-scope* (make-lexical-scope - :kind :workspace - :location nil - :parent-scope nil - :symbol-definitions '() - ;; Should never actually receive values - :symbol-references (make-hash-table) - :child-scopes '() - :node nil)) - - (load-common-lisp-builtin-symbols *global-scope*) - (load-asd-external-packages *global-scope*) + (setf ctx:global-scope (make-lexical-scope + :kind :workspace + :location nil + :parent-scope nil + :symbol-definitions '() + ;; Should never actually receive values + :symbol-references (make-hash-table) + :child-scopes '() + :node nil)) + + (load-common-lisp-builtin-symbols ctx:global-scope) + (load-asd-external-packages ctx:global-scope) (slog :debug "Building symbol map at ~A" project-root) ;; Discover every .lisp file recursively under the root @@ -205,13 +197,13 @@ Note that symbol-ref can be nil if none is at the location" (setf *current-package* nil) ;; Calculate and store line lengths for this document - (setf (gethash file-path *document-line-offsets*) + (setf (gethash file-path ctx:document-line-offsets) (calculate-line-offsets file-source)) ;; Init the interval trees - (setf (gethash file-path *symbol-refs-by-file*) + (setf (gethash file-path ctx:symbol-refs) (interval:make-tree)) - (setf (gethash file-path *lexical-scopes-by-file*) + (setf (gethash file-path ctx:lexical-scopes) (interval:make-tree)) ;; Parse the file with tree-sitter and then walk the output tree to find @@ -226,14 +218,14 @@ Note that symbol-ref can be nil if none is at the location" :file-path file-path :start 0 :end (length file-source)) - :parent-scope *global-scope* + :parent-scope ctx:global-scope :symbol-definitions '() :symbol-references (make-hash-table) :child-scopes '() :node parse-tree)) ;; Append as a child-scope of the global scope - (push *current-scope* (lexical-scope-child-scopes *global-scope*)) + (push *current-scope* (lexical-scope-child-scopes ctx:global-scope)) ;; Store the document scope on the interval tree so it can be found by find-all (store-scope-on-interval-tree *current-scope* file-path) (labels ((walk (n) @@ -347,7 +339,8 @@ those packages' members into the symbol map" "Retrieves a combined list of library names from all loaded systems. Aggregates :depends-on from all discovered .asd files and filters out local system names." (let ((all-deps '()) - (local-system-names '())) + (local-system-names '()) + (systems ctx:loaded-systems)) ;; Collect all local system names and their dependencies (maphash (lambda (name sys-info) (push name local-system-names) @@ -357,12 +350,12 @@ Aggregates :depends-on from all discovered .asd files and filters out local syst (let ((dep-str (string-downcase (if (stringp dep) dep (symbol-name dep))))) (pushnew dep-str all-deps :test #'string-equal))))) - clef-lsp/lifecycle::*loaded-systems*) + systems) ;; Filter out local system names (don't try to load our own systems as external) (let ((external-deps (set-difference all-deps local-system-names :test #'string-equal))) (slog :debug "[symbol init] Found ~A external dependencies from ~A system(s)" (length external-deps) - (hash-table-count clef-lsp/lifecycle::*loaded-systems*)) + (hash-table-count systems)) ;; Convert back to symbols for compatibility with existing code (mapcar (lambda (s) (intern (string-upcase s) :keyword)) external-deps)))) @@ -473,7 +466,7 @@ symbol-definitions. Returns the created lexical-scope if applicable, nil otherwi (defun store-scope-on-interval-tree (scope file-path) "Stores the given lexical SCOPE into the interval tree for FILE-PATH." - (let ((scopes-tree (gethash file-path *lexical-scopes-by-file*)) + (let ((scopes-tree (gethash file-path ctx:lexical-scopes)) (new-interval (make-clef-interval :start (location-start (lexical-scope-location scope)) :end (location-end (lexical-scope-location scope))))) @@ -594,7 +587,7 @@ interval tree if so." :location (location-for-node file-path node) :usage-scope *current-scope* :node node))) - (let ((refs-tree (gethash file-path *symbol-refs-by-file*)) + (let ((refs-tree (gethash file-path ctx:symbol-refs)) (new-interval (make-clef-interval :start (location-start (symbol-reference-location symbol-reference)) :end (location-end (symbol-reference-location symbol-reference))))) diff --git a/test/document-tests.lisp b/test/document-tests.lisp index 62c7b3a..436cae4 100644 --- a/test/document-tests.lisp +++ b/test/document-tests.lisp @@ -49,7 +49,7 @@ "text" *simple-lisp-code*)) :id nil) (assert-equal *simple-lisp-code* - (gethash "file:///tmp/test.lisp" clef-lsp/server:*documents*) + (gethash "file:///tmp/test.lisp" clef-context:documents) "Document text should be stored"))) (deftest test-did-open-multiple-documents @@ -69,9 +69,9 @@ "text" "(defun b () 2)")) :id nil) (assert-equal "(defun a () 1)" - (gethash "file:///tmp/a.lisp" clef-lsp/server:*documents*)) + (gethash "file:///tmp/a.lisp" clef-context:documents)) (assert-equal "(defun b () 2)" - (gethash "file:///tmp/b.lisp" clef-lsp/server:*documents*)))) + (gethash "file:///tmp/b.lisp" clef-context:documents)))) ;;; textDocument/didChange tests @@ -93,7 +93,7 @@ "contentChanges" (vector (dict "text" "(defun new () t)"))) :id nil) (assert-equal "(defun new () t)" - (gethash "file:///tmp/test.lisp" clef-lsp/server:*documents*) + (gethash "file:///tmp/test.lisp" clef-context:documents) "Document should be updated"))) ;;; textDocument/hover tests diff --git a/test/lifecycle-tests.lisp b/test/lifecycle-tests.lisp index e73bb55..e5253fa 100644 --- a/test/lifecycle-tests.lisp +++ b/test/lifecycle-tests.lisp @@ -24,7 +24,7 @@ (with-direct-handler-test (call-handler "initialize" (make-minimal-initialize-params)) (assert-equal "file:///tmp/test-workspace" - clef-lsp/server:*workspace-root* + clef-context:workspace-root "Workspace root should be set"))) (deftest test-initialize-stores-client-capabilities @@ -36,7 +36,7 @@ "workspaceFolders" (vector (dict "uri" "file:///tmp/test" "name" "test"))))) (call-handler "initialize" params) - (assert-not-nil clef-lsp/server:*client-capabilities* + (assert-not-nil clef-context:client-capabilities "Client capabilities should be stored")))) (deftest test-initialized-sets-flag @@ -46,7 +46,7 @@ (call-handler "initialize" (make-minimal-initialize-params)) ;; Then send initialized notification (call-handler "initialized" (dict) :id nil) - (assert-true clef-lsp/server:*initialized* + (assert-true clef-context:initialized "Server should be marked as initialized"))) (deftest test-server-not-initialized-error @@ -68,11 +68,11 @@ (call-handler "initialize" (make-minimal-initialize-params)) (call-handler "initialized" (dict) :id nil) ;; Verify initialized - (assert-true clef-lsp/server:*initialized*) + (assert-true clef-context:initialized) ;; Shutdown (call-handler "shutdown" (dict)) ;; State should be reset - (assert-nil clef-lsp/server:*initialized* + (assert-nil clef-context:initialized "Server should not be initialized after shutdown"))) (deftest test-capabilities-include-expected-providers