Task 26 — Wire the LSP client to surface publishDiagnostics results in the editor with inline squiggles, hover tooltips, incremental document sync, and status bar counts.
LSP server stdout
→ spawn_stdout_reader (background thread, JSON-RPC framing)
→ StdoutMsg::Message(Value) via mpsc channel
→ worker_main dispatches to handle_server_message()
→ handle_publish_diagnostics() converts to DiagnosticEntry vec
→ LspManagerEvent::Diagnostics sent to UI thread
→ handle_lsp_events() stores in AppState.diagnostics (DiagnosticMap)
→ FerriteEditor reads diagnostics via EditorWidget builder
→ render_diagnostic_squiggles() draws wavy underlines
→ Hover tooltip shows message at pointer position
pub struct DiagnosticEntry {
pub start_line: usize, // 0-indexed
pub start_col: usize,
pub end_line: usize,
pub end_col: usize,
pub severity: DiagnosticSeverity, // Error | Warning | Information | Hint
pub message: String,
pub source: Option<String>,
}HashMap<PathBuf, Vec<DiagnosticEntry>> with convenience methods:
| Method | Description |
|---|---|
set(path, entries) |
Replace diagnostics for a file |
get(path) |
Get diagnostics for a file |
for_line_range(path, start, end) |
Filter to visible line range |
counts() |
Sum (errors, warnings) across all files |
clear() |
Remove all diagnostics |
Task 26 implements the full LSP initialize/initialized handshake:
send_initialize()— sendsinitializerequest with client capabilities (sync = full, diagnostics)- Server responds →
handle_server_message()setsserver.initialized = true send_initialized()— sends emptyinitializednotification
Full-text sync (TextDocumentSyncKind::Full):
| Notification | When | Source |
|---|---|---|
textDocument/didOpen |
Tab becomes active + server is Ready | sync_active_doc_to_lsp() |
textDocument/didChange |
last_edit_time changes (debounced 300ms) |
sync_active_doc_to_lsp() |
textDocument/didClose |
Not yet wired (future enhancement) |
Tracking state lives on FerriteApp:
lsp_opened_docs: HashSet<PathBuf>— normalized paths already sentdidOpenlsp_doc_versions: HashMap<PathBuf, i32>— version counter per pathlsp_last_edit_times: HashMap<PathBuf, Instant>— snapshot ofTab::last_edit_timeat last synclsp_last_change_sent: HashMap<PathBuf, Instant>— debounce: when lastdidChangewas sent
Path normalization: All path keys are normalized via normalize_lsp_path() (uppercased drive letter on Windows, \\?\ prefix stripped) so that URIs from the server and filesystem paths from tabs always match.
Server sends textDocument/publishDiagnostics → handle_publish_diagnostics():
- Converts URI to
PathBufviauri_to_path() - Parses each JSON diagnostic into
DiagnosticEntry - Sends
LspManagerEvent::Diagnosticsto UI thread
handle_lsp_events() in file_ops.rs calls self.state.diagnostics.set(path, entries).
render_diagnostic_squiggles() draws wavy underlines for diagnostics overlapping visible lines:
- Error → red squiggle
- Warning → yellow/orange squiggle
- Info/Hint → blue squiggle (dark) / darker blue (light)
The squiggle is drawn as a series of connected line segments forming a sine-wave pattern with amplitude 2px and period 4px, positioned at the baseline of the text.
Only diagnostics overlapping the visible line range (start_line..end_line) are processed.
When the pointer hovers over the editor area:
- Convert pointer position to buffer cursor via
pos_to_cursor() - Check each diagnostic with
cursor_in_diagnostic_range() - Show
egui::show_tooltip_at_pointerwith severity icon + message
Displays error/warning counts next to the LSP status line:
- Red label for errors:
"N err" - Yellow label for warnings:
"N warn" - Only shown when counts > 0
| Function | Description |
|---|---|
normalize_lsp_path(path) |
Normalize path for diagnostic map keys (uppercase drive, strip \\?\) |
path_to_uri(path) |
Convert PathBuf to file:// URI (normalizes first, handles Windows) |
language_id_for_path(path) |
Map extension to LSP languageId (rust, python, go, etc.) |
| File | Changes |
|---|---|
src/lsp/state.rs |
DiagnosticSeverity, DiagnosticEntry, DiagnosticMap |
src/lsp/manager.rs |
Full stdout read loop, initialize handshake, didOpen/didChange/didClose, publish diagnostics handling |
src/lsp/mod.rs |
path_to_uri(), language_id_for_path(), re-exports |
src/state.rs |
AppState.diagnostics: DiagnosticMap field |
src/app/mod.rs |
lsp_opened_docs, lsp_doc_versions, lsp_content_hashes tracking fields |
src/app/file_ops.rs |
handle_lsp_events() → Diagnostics variant, sync_active_doc_to_lsp() |
src/app/central_panel.rs |
Pass tab_diagnostics to EditorWidget |
src/editor/widget.rs |
diagnostics field + builder method on EditorWidget |
src/editor/ferrite/editor.rs |
diagnostics field, hover tooltip logic |
src/editor/ferrite/highlights.rs |
render_diagnostic_squiggles(), draw_squiggle(), cursor_in_diagnostic_range() |
src/app/status_bar.rs |
Error/warning count display |
- Open a Rust workspace with
rust-analyzerinstalled - Introduce a syntax error (e.g., remove a semicolon)
- Verify: red squiggle appears under the error
- Hover over squiggle → tooltip shows diagnostic message
- Fix the error → squiggle disappears after server re-analyzes
- Status bar shows error/warning counts