Status: P0 done; unused import, unused local variable, duplicate import, and identifier-not-exported done; precise undefined identifier and type-mismatch remain
Created: 2025-05-13
Updated: 2026-06-24
The diagnose subsystem stores raw diagnostics as lists with the following shape (backward-compatible forms are still accepted):
(start-bias end-bias severity message [source] [code])
start-bias/end-bias — character offsets into the document text.severity — LSP severity integer (1=Error, 2=Warning, 3=Information, 4=Hint).message — human-readable string.source — optional subsystem tag (e.g. "syntax", "identifier", "import", "load", "type").
When omitted, private:make-diagnostic falls back to "scheme-langserver".code — optional diagnostic code (e.g. "syntax-error", "library-not-found").LSP conversion (protocol/apis/document-diagnostic.sls) emits a Diagnostic
object with range, severity, message, source, and (when present) code.
The following LSP fields are still not emitted:
| LSP field | Current support | Impact when missing |
|---|---|---|
source |
✅ | Diagnostics are tagged by subsystem. |
code |
✅ | Most common diagnostics have a stable code. |
tags |
✅ | Unnecessary is emitted for unused-import and unused-local-variable. Deprecated is not used yet. |
relatedInformation |
❌ | Cannot show “defined here” / “imported here” cross-references. |
| Source | File | Severity | source |
code |
Message example |
|---|---|---|---|---|---|
| Tokenizer / Parser | analysis/tokenizer.sls |
1 (Error) |
"syntax" |
"syntax-error" |
"Syntax error: ..." |
| File not found | analysis/tokenizer.sls |
1 (Error) |
"syntax" |
"file-not-found" |
"File not found: ..." |
| Duplicate identifier | analysis/identifier/reference.sls |
1 (Error) |
"identifier" |
"duplicate-identifier" |
"Duplicate identifier: x" |
| Abstract interpreter | analysis/abstract-interpreter.sls |
2 (Warning) |
"identifier" |
"identifier-resolution-failure" |
"Scheme-langserver Warning: fail to catch identifiers" |
| Library import (r6rs) | analysis/identifier/rules/library-import.sls |
2 (Warning) |
"import" |
"library-not-found" |
"Fail to find library:..." |
| Library import (r7rs) | analysis/identifier/rules/r7rs/define-library-import.sls |
2 (Warning) |
"import" |
"library-not-found" |
"Fail to find library:..." |
| File load | analysis/identifier/rules/load.sls |
2 (Warning) |
"load" |
"load-file-not-found" |
"Fail to find file:..." |
| Unused import | analysis/workspace.sls |
2 (Warning) |
"import" |
"unused-import" |
"Unused import: car" |
| Unused local variable | analysis/workspace.sls |
2 (Warning) |
"identifier" |
"unused-local-variable" |
"Unused local variable: x" |
| Duplicate import | analysis/workspace.sls |
2 (Warning) |
"import" |
"duplicate-import" |
"Duplicate import: (rnrs)" |
| Identifier not exported | analysis/identifier/rules/library-import.sls |
2 (Warning) |
"import" |
"identifier-not-exported" |
"Identifier not exported: foo" |
| Type inference | analysis/workspace.sls |
2 (Warning) |
"type" |
"type-inference-warning" |
"Type inference warning: ..." |
| Type rules | analysis/type/substitutions/generator.sls |
2 (Warning) |
"type" |
"type-rule-warning" |
"Type rule warning: ..." |
| Analysis error | analysis/workspace.sls |
1 (Error) |
"analysis" |
"analysis-error" |
"Analysis error: ..." |
type:->?, type:=?)
exists but is only used for hover tooltips. It is not wired into
publishDiagnostics.unused-local-variable
(analysis/workspace.sls). Local bindings (parameters, let/let*/letrec/
let-values/do/with-syntax bindings, and top-level defines in scripts)
with zero usage-count are flagged, unless they are exported.source + code) ✅ DoneGoal: Make diagnostics professional and filterable.
Implementation status:
source and 6th code
fields:
;; Backward-compatible forms
(start-bias end-bias severity message) ; source="scheme-langserver", code=#f
(start-bias end-bias severity message source) ; code=#f
(start-bias end-bias severity message source code) ; full
private:make-diagnostic in protocol/apis/document-diagnostic.sls emits
source and, when present, code.append-new-diagnoses call sites now supply a source, and most common
diagnostics also have a code.source values in use:
| Subsystem | source string |
|---|---|
| tokenizer / parser | "syntax" |
| abstract interpreter (identifier resolution) | "identifier" |
| library / import resolution | "import" |
| file load | "load" |
| type inference / type rules | "type" |
| analysis error (threaded exception fallback) | "analysis" |
code values in use:
| Diagnostic | code |
|---|---|
| Syntax error (tokenizer) | "syntax-error" |
| File not found (tokenizer) | "file-not-found" |
| Duplicate identifier | "duplicate-identifier" |
| Library not found | "library-not-found" |
| File not found (load) | "load-file-not-found" |
| Identifier resolution failure | "identifier-resolution-failure" |
| Unused import | "unused-import" |
| Unused local variable | "unused-local-variable" |
| Duplicate import | "duplicate-import" |
| Identifier not exported | "identifier-not-exported" |
| Type inference warning | "type-inference-warning" |
| Type rule warning | "type-rule-warning" |
| Analysis error | "analysis-error" |
The following codes are published to LSP clients. They are generated in
analysis/workspace.sls and analysis/identifier/rules/library-import.sls, then
converted to LSP Diagnostic objects in protocol/apis/document-diagnostic.sls.
code |
Severity | Source | Example message | LSP tags |
|---|---|---|---|---|
"duplicate-identifier" |
1 Error |
"identifier" |
"Duplicate identifier: x" |
— |
"library-not-found" |
2 Warning |
"import" |
"Fail to find library: (rnrs base)" |
— |
"unused-import" |
2 Warning |
"import" |
"Unused import: car" |
#(1) (Unnecessary) |
"unused-local-variable" |
2 Warning |
"identifier" |
"Unused local variable: x" |
#(1) (Unnecessary) |
"duplicate-import" |
2 Warning |
"import" |
"Duplicate import: (rnrs)" |
— |
"identifier-not-exported" |
2 Warning |
"import" |
"Identifier not exported: foo" |
— |
"syntax-error" |
1 Error |
"syntax" |
"Syntax error: ..." |
— |
(import (only (rnrs) car cdr))
(display cdr)
;; Diagnostic on `car`:
;; (start end 2 "Unused import: car" "import" "unused-import")
;; LSP tags: #(1) => Unnecessary
(import (rnrs))
(import (rnrs))
;; Diagnostic on the second `(rnrs)`:
;; (start end 2 "Duplicate import: (rnrs)" "import" "duplicate-import")
(import (only (fixtures import-test lib) foo not-exported))
;; Diagnostic on `not-exported`:
;; (start end 2 "Identifier not exported: not-exported" "import" "identifier-not-exported")
Goal: Replace the blanket "fail to catch identifiers" with
"Undefined identifier: foo" at the exact symbol location.
Implementation sketch:
abstract-interpreter.sls, when head-expression is a symbol and
private:find-available-references-for returns '(), emit a diagnostic
with:
1 (Error)(string-append "Undefined identifier: " (symbol->string head-expression))"identifier""undefined-identifier"Caveats:
quote / quasiquote (already guarded by existing
quote? / quasiquote? branches).Estimated effort: 2–3 days.
Goal: Use the existing type system to emit warnings when argument types do not match a function’s signature.
Implementation sketch:
analysis/type/substitutions/rules/application.sls, after inferring the
callee’s type and the arguments’ types, call type:->? for each parameter.type:->? actual expected is #f and neither type is something?
(unknown), emit:
(start end 2 ; Warning
(string-append "Type mismatch: expected " (type->string expected)
", got " (type->string actual))
"type" "type-mismatch")
Caveats:
(list? a ...)) must be instantiated before comparison.Warning (not Error).Estimated effort: 1–2 weeks.
Goal: Mark variables and imports that are declared but never referenced.
Implementation status:
abstract-interpreter.sls
increments identifier-reference-usage-count when a leaf symbol is resolved.
After step, analysis/workspace.sls:private:check-unused-imports walks each
import clause and emits "Unused import: ..." for imported identifiers whose
usage-count is still 0.
only, except, rename, alias — checked per identifier.analysis/identifier/rules/library-import.sls and reading them back in
private:check-import-clause."unused-local-variable" and tagged as Unnecessary.Bug fixed along the way:
library-identifier->string in analysis/identifier/util.sls used
string-trim (SRFI-13 trims only the left side in this implementation), so
unused-import and library-not-found messages ended with a trailing newline.
Switched to string-trim-both.Remaining implementation sketch (unused variable):
document-ordered-reference-list and emit diagnostics
for items with zero references:
variable type → "Unused variable: foo"Unnecessary tag once tags support is introduced.Caveats:
(rnrs),
(chezscheme)) are considered “used” whenever the file uses any of those
standard bindings, because the resolver sees the binding as coming from every
import that exports it. This is consistent with the current reference-counting
model but may produce fewer warnings than a purely semantic analysis would.Estimated effort: 1–2 weeks (down from 2–3 weeks because the reference
infrastructure and usage-count already exist).
relatedInformationGoal: Cross-reference diagnostics with their definition / import sites.
Example: When a library is not found, include a relatedInformation entry
pointing to the import clause that requested it.
Implementation sketch:
private:make-diagnostic to accept an optional list of
DiagnosticRelatedInformation objects.location (uri + range) and message.Estimated effort: 1 week.
| Priority | Item | Status | Effort | User value |
|---|---|---|---|---|
| P0 | source + code metadata |
✅ Done | — | High (professionalism) |
| P1 | Precise undefined identifier | ❌ Not started | 2–3 days | Very high (most-requested feature) |
| P2 | Type-mismatch warnings | ❌ Not started | 1–2 weeks | High (core differentiator) |
| P3 | Unused import | ✅ Done | — | Medium (code quality) |
| P3 | Unused local variable | ✅ Done | — | Medium (code quality) |
| P3 | Duplicate import | ✅ Done | — | Medium (code quality) |
| P3 | Identifier not exported | ✅ Done | — | Medium (code quality) |
| P4 | relatedInformation |
❌ Not started | 1 week | Medium (UX polish) |
If a full phase is too large, these can be done in minutes:
source field change):
;; Before
"Fail to find library:rnrs-base"
;; After
"[import] Fail to find library: rnrs-base"
"fail to catch identifiers" is currently Warning (2). If it becomes
an undefined-identifier diagnostic, it should be Error (1).load.sls: The file has a ;;todo more test comment but
zero test coverage.
This document describes how scheme-langserver generates, accumulates, and
publishes diagnostics to the LSP client via textDocument/publishDiagnostics.
scheme-langserver uses a push model for diagnostics: the server periodically
publishes diagnostic notifications to the client. The internal trigger is a
request method named private:publish-diagnostics, which is produced by an
interval timer and processed through the same single-consumer request queue as
all other LSP requests.
Key design goals:
didOpen/didChange/didClose)
are never interrupted by engine time-slicing.When the server starts in multi-threaded mode (thread-pool is non-#f),
init-server creates an interval-timer with a 1-second period:
(init-interval-timer
(make-time 'time-duration 0 1)
(lambda ()
(request-queue-push request-queue
(make-request '() "private:publish-diagnostics" '())
(server-workspace server-instance)))
...)
The timer callback simply pushes a private:publish-diagnostics request into the
queue. The actual publication happens later when the worker thread pops and
executes it.
request-queue-push treats private:publish-diagnostics specially:
["private:publish-diagnostics"
(let* ([predicator ...]
[tickal-task (find predicator (request-queue-tickal-task-list queue))])
(when (not tickal-task)
(make-tickal-task request queue workspace)))]
If a private:publish-diagnostics task already exists in tickal-task-list
(either pending in the queue or currently running), the new request is dropped.
This guarantees at most one publish task is alive at any moment.
textDocument/didChangeWhen a didChange arrives, request-queue-push walks tickal-task-list and
sets stop? = #t on every private:publish-diagnostics task it finds (among
others). The old publish task is therefore cancelled, because the document has
changed and its diagnostics are stale.
Important:
didChangedoes not enqueue a replacement publish task. The client must wait until the next timer tick (up to 1 second) to receive updated diagnostics.
Diagnostics flow through four stages: generation, accumulation, publication, and cleanup.
Every diagnostic is attached to a document record (field diagnoses).
The generation pipeline is triggered by init-references (batch analysis) or
refresh-workspace-for (incremental analysis).
private-init-referencesFor each target path (after init-references has already cleared stale diagnostics and import/export references serially):
step):
usage-count on every identifier-reference that is successfully resolved as a leaf symbol (used for unused-import detection below).(append-new-diagnoses current-document
`(start end 2 "Scheme-langserver Warning: fail to catch identifiers"))
process-library-identifier-excluded-references):
import forms.(append-new-diagnoses document
`(start end 2 "Fail to find library:..."))
load (“Fail to find file:…”).lambda, let, do, define, case-lambda, let*, letrec, let-values, etc.) calls check-duplicate-identifiers (in analysis/identifier/util.sls) after extracting parameter pairs.`(start end 1 "Duplicate identifier: x" "identifier" "duplicate-identifier")
private:check-unused-imports):
step and process-library-identifier-excluded-references.import clause in the document.identifier-reference, checks whether usage-count is 0.only, except, rename, and alias.`(start end 2 "Unused import: car" "import" "unused-import")
library-identifier is '() or built-in libraries such as (rnrs)) are skipped.type-inference? is enabled):
construct-substitutions-for.(document-refreshable?-set! document #f)
This prevents the same document from being re-analysed until the next change.
A raw diagnose is stored as a list with 4 to 6 elements:
(range-start range-end severity message [source] [code])
range-start, range-end — byte offsets into the document text.severity — LSP severity integer (1=Error, 2=Warning, 3=Information, 4=Hint).message — human-readable string.source — optional subsystem tag (e.g. "import", "identifier").
When omitted, private:make-diagnostic falls back to "scheme-langserver".code — optional stable diagnostic code (e.g. "unused-import").
When present, it is published to the client as the LSP code field.workspace-undiagnosed-pathsNot every file with diagnostics is published immediately. Instead, paths are
stored in a workspace field called undiagnosed-paths.
| Event | Code location | What happens |
|---|---|---|
| Workspace init | init-workspace |
All paths from get-init-reference-batches are appended to undiagnosed-paths. |
| Full refresh | refresh-workspace |
Same as init — all batch paths are appended. |
| Incremental refresh | refresh-workspace-for |
The changed file and its dependency-closure paths are merged into undiagnosed-paths. |
The merge uses ordered-dedupe to keep the list sorted and unique:
(workspace-undiagnosed-paths-set! workspace-instance
(ordered-dedupe
(merge string<?
(workspace-undiagnosed-paths workspace-instance)
(sort string<? path))
string=?))
Why not publish immediately? Batch
init-referencesmay re-analyse dozens of files. Collecting paths and publishing once per second amortises JSON serialization and I/O overhead.
unpublish-diagnostics->listWhen the worker thread eventually executes private:publish-diagnostics, it
calls private:publish-diagnostics in scheme-langserver.sls, which delegates
to unpublish-diagnostics->list:
(define (unpublish-diagnostics->list workspace)
(let ([result
(map
(lambda (d)
(make-alist
'uri (document-uri d)
'diagnostics (private:document->diagnostic-vec d)))
(filter
(lambda (node) (not (null? node)))
(map
(lambda (s)
(let ([file-node (walk-file (workspace-file-node workspace) s)])
(if (null? file-node) '() (file-node-document file-node))))
(workspace-undiagnosed-paths workspace))))])
(workspace-undiagnosed-paths-set! workspace '())
result))
Data transformation steps:
walk-file locates the file-node for each path.
If the path is stale (file deleted), '() is returned and skipped.file-node-document extracts the document.private:document->diagnostic-vec
converts each 4-tuple into a JSON-serialisable alist with range,
severity, and message.undiagnosed-paths is reset to '().Note: empty diagnostics are not filtered out. When a document has zero diagnoses, an empty
diagnosticsarray is sent so the client clears any stale errors. See Bug 1 below.
The resulting list of alists is then iterated by private:publish-diagnostics,
which sends one textDocument/publishDiagnostics notification per document:
(for-each
(lambda (params)
(send-message server-instance
(make-notification "textDocument/publishDiagnostics" params)
'publish))
(unpublish-diagnostics->list (server-workspace server-instance)))
After a successful publish:
workspace-undiagnosed-paths is '().document still retains its diagnoses list (it is not
cleared after publishing).This means if a client reconnects or a pull-diagnostic request arrives later, the same diagnostics are still available in-memory.
┌─────────────────────────────────────────────────────────────────────┐
│ Client edits a file │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ textDocument/didChange → request-queue-push │
│ - cancels old publish-diagnostics tasks (stop? = #t) │
│ - enqueues didChange itself (non-interruptible) │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Worker thread pops didChange → update document text & index │
│ → refresh-workspace-for(target-file-node) │
└─────────────────────────────────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Add target │ │ Add dep- │ │ init-references
│ path to │ │ closure │ │ (re-analyse)
│ undiagnosed │ │ paths to │ │
│ paths │ │ undiagnosed │ │ step / type inference
└─────────────┘ │ paths │ │ → document-diagnoses
└─────────────┘ └─────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Interval timer (1 s) → push private:publish-diagnostics │
│ (dedup: skipped if one already exists) │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Worker thread pops publish-diagnostics │
│ → unpublish-diagnostics->list │
│ - walk-file → file-node (or '() if deleted) │
│ - file-node-document → document │
│ - private:document->diagnostic-vec → LSP format │
│ - clear undiagnosed-paths │
│ → send-message "textDocument/publishDiagnostics" │
└─────────────────────────────────────────────────────────────────────┘
Location: protocol/apis/document-diagnostic.sls, inside
unpublish-diagnostics->list.
Problem: the function filtered out documents whose document-diagnoses was
empty:
(filter
(lambda (d) (not (null? (document-diagnoses d))))
...)
When a user fixes an error (e.g. corrects a misspelled library import), the
server re-analyses the file, clears document-diagnoses, and places the path in
undiagnosed-paths. On the next timer tick publish-diagnostics runs, but
because the document now has zero diagnoses, it is dropped from the publish
list. The client never receives an update for that document, so the old
diagnostic remains visible forever.
Trigger scenario:
(import (nonexistent-lib))."Fail to find library:nonexistent-lib".document-diagnoses becomes '().publish-diagnostics sends nothing for this document.Fix: remove the (not (null? (document-diagnoses d))) filter. An empty
diagnostics array in textDocument/publishDiagnostics is the LSP-compliant
way to tell the client to clear diagnostics for that document.
walk-file returning '() causes a crashLocation: protocol/apis/document-diagnostic.sls, inside
unpublish-diagnostics->list.
Problem: walk-file returns '() when a path no longer exists in the
virtual file system (e.g. the file was deleted after the path was added to
undiagnosed-paths). The old code chained:
(map file-node-document
(map (lambda (s) (walk-file ... s)) ...))
file-node-document was called on '(), which is not a file-node record,
raising a type error and crashing the server.
Trigger scenario:
a.scm is opened → path added to undiagnosed-paths.a.scm is deleted externally.publish-diagnostics tries to walk the stale path → crash.Fix: guard each walk-file result and skip '() before calling
file-node-document.
undiagnosed-paths is not cleared if publication failsLocation: protocol/apis/document-diagnostic.sls.
The reset (workspace-undiagnosed-paths-set! workspace '()) happens after
result is fully computed. If an exception inside the traversal aborts
execution, control never reaches the set!. The stale paths remain in
undiagnosed-paths.
Consequences:
Fix: snapshot undiagnosed-paths into a local variable and clear the
workspace field before starting the traversal. Even if an exception aborts
the fold, the paths have already been removed from the accumulator.
textDocument/diagnostic)Location: protocol/apis/document-diagnostic.sls and seven other API files.
Problem: the exact pattern (walk-file + substring fallback +
file-node-document) was copy-pasted into eight API files:
hover, definition, completion, document-symbol, document-highlight,
formatting, references, and document-diagnostic. If both walk-file
calls returned '(), file-node-document crashed.
Fix: extracted the pattern into a shared helper resolve-uri->file-node
in virtual-file-system/file-node.sls. It guards against '() before
returning, and all eight API files now use it.
for-each instead of map when the result is discardedLocation: scheme-langserver.sls:63 (private:publish-diagnostics).
Changed from map to for-each since the list is discarded and only the
side-effect (send-message) matters.
unpublish-diagnostics->listLocation: protocol/apis/document-diagnostic.sls:26-38.
Replaced the four nested map/filter passes with a single fold-right
that walks undiagnosed-paths once, accumulating valid LSP diagnostic params.
didChangeStatus: rejected.
The 1-second interval timer provides debounce for rapid successive edits.
If didChange immediately enqueued a publish task, fast typing would trigger
repeated refresh-workspace-for / init-references calls, wasting CPU and
slowing down the worker thread.
Moreover, the dominant latency is not the timer wait but the index update
itself (refresh-workspace-for → abstract interpreter → type inference).
Even if publish were triggered instantly, the client would still wait for the
analysis to finish. The timer therefore offers a cheap, natural coalescing
point without adding extra complexity.
Aligned the internal request method and handler function to both use
private:publish-diagnostics (ends in tics), matching the LSP standard
textDocument/publishDiagnostics.
Eight protocol API files previously contained a hard-coded fallback:
(substring (text-document-uri text-document) 7 (string-length ...))
This assumed the URI prefix is exactly file:// (7 characters). The logic has
been extracted into resolve-uri->file-node in
virtual-file-system/file-node.sls, which tries uri->path first and falls
back to stripping the prefix only when the URI actually starts with file://.
All eight API files now use the shared helper.
| File | Role |
|---|---|
scheme-langserver.sls |
private:publish-diagnostics handler, interval timer setup |
protocol/analysis/request-queue.sls |
Dedup, cancellation, and enqueue logic for private:publish-diagnostics |
protocol/apis/document-diagnostic.sls |
unpublish-diagnostics->list, diagnostic (pull), and LSP formatting |
analysis/workspace.sls |
undiagnosed-paths management, init-references, refresh-workspace-for |
analysis/abstract-interpreter.sls |
step — identifier resolution and warning generation |
analysis/identifier/rules/library-import.sls |
Library-not-found diagnostics |
virtual-file-system/document.sls |
document-diagnoses, append-new-diagnoses |
virtual-file-system/file-node.sls |
walk-file, resolve-uri->file-node |