LSP timesheet diagnostics: doubled error prefix, duplicate publish, and missing cross-file timesheets #136
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found while inspecting an LSP session log. Three related issues in
textDocument/diagnostichandling for R18 timesheet checks (src/cli/commands/lsp.rs):1. Doubled "Timesheet error:" prefix
StreamdError::TimesheetErroralready formats as"Timesheet error: {0}"(src/error.rs:8).compute_diagnosticsinsrc/cli/commands/lsp.rs:524wraps the already-formatted error again:Result:
"Timesheet error: Timesheet error: Last Timecard of 2025-11-03 is not a break!".Fix: use
e.to_string()(orformat!("{}", e)) without the extra prefix.2. Diagnostics published twice, in inconsistent order
did_open(src/cli/commands/lsp.rs:728) unconditionally computes diagnostics and pushes them viapublish_diagnostics. The server also implements pull diagnostics (textDocument/diagnostic, handled aroundlsp.rs:879), which independently recomputes and returns the same diagnostics in the RPC response. A client that opens a file and then pulls diagnostics gets the same diagnostic twice (once as apublishDiagnosticsnotification, once as the request response), and the relative ordering between the two is not deterministic — observed as response-then-push for one file and push-then-response for another in the same session, indicating a race rather than a defined sequence.Should settle on one diagnostic delivery model per client capabilities (respect
textDocument.diagnosticclient capability to decide whether to also push ondidOpen/didChange), or at minimum keep the two paths from stepping on each other.3. Timesheet validation in the LSP is file-scoped, but timesheets are not
compute_diagnosticsonly extracts timesheets from the single open file's shard:But timesheets are a repository-wide concept —
src/cli/commands/timesheet.rs:328builds the real report from all markdown shards in the base folder (load_markdown_shards, walked non-recursively), andextract_timesheetsgroups timecards by date across files. A day's entries can legitimately span more than one file (e.g. a stream file plus a note file dated the same day), so checking only the currently-open file againstextract_timesheetscan both:Fix: LSP diagnostics for R18 should load and merge shards for the whole base folder (like
load_markdown_shardsdoes for the CLItimesheetcommand) rather than just the single open file, then filter/report diagnostics for the open document's date(s). Needs some thought on caching (avoid re-walking + re-parsing the whole folder on every keystroke) — likely reuse/extend the existingfile_cache(DashMap) to hold all repo files, not just open ones, and recompute the merged timesheet set from that cache.Suggested scope for this ticket
Implementation plan
1. Doubled "Timesheet error:" prefix — trivial
src/cli/commands/lsp.rs:524: changeto
StreamdError'sDisplay(viathiserror,src/error.rs:8) already renders the full"Timesheet error: ..."text.2. Stop double-publishing diagnostics
Root cause: the server always pushes (
publish_diagnostics) fromdid_open/did_change/did_saveand answers pull requests (textDocument/diagnostic), regardless of what the client asked for.initialize(lsp.rs:577), readparams.capabilities.text_document.as_ref().and_then(|td| td.diagnostic.as_ref()).is_some()and store it assupports_pull_diagnostics: boolonLspState(set once inBackend::build_state, alongsideconfig/tz/base_folder).did_open,did_change,did_save(lsp.rs:728,740,754): only callself.client.publish_diagnostics(...)when!state.supports_pull_diagnostics. Still always callstate.parse_and_cacheso the cache is warm for a subsequent pull.diagnostic_provider'sinter_file_dependenciesfromfalsetotrue(lsp.rs:631) once #3 lands — diagnostics in one file can now depend on another file's content, which is exactly what that flag communicates to the client.textDocument.diagnosticsupport is explicitly opting into pull mode, so unsolicited pushes are redundant per spec, not just noisy in this server.3. Cross-file timesheets in the LSP
Today
compute_diagnostics(lsp.rs:471) only feeds the single open file's cached shard intoextract_timesheets. Timesheets are grouped by date across the whole repo (seetimesheet.rs:328'sload_markdown_shards+extract_timesheets(&all_shards, ...)for the CLI'sstreamd timesheetcommand), so a day split across two files can produce false positives/negatives when checked one file at a time.Approach — reuse the cross-file iteration pattern already used by
workspace_diagnostic/symbol/todo(lsp.rs:937,992,1048), which walkslist_markdown_files(&state.base_folder)and lazily fillsfile_cacheviaparse_file_from_disk:LspState::all_shards(&self) -> Vec<LocalizedShard>: iteratelist_markdown_files(&self.base_folder), resolve each to aUrl, get-or-populatefile_cache(same lazy pattern as the existing loops), and collect theSome(root)entries.compute_diagnosticsto callextract_timesheets(&self.all_shards(), now, self.tz)instead ofextract_timesheets(std::slice::from_ref(root), ...).Timesheet/TimesheetPoint(src/timesheet/extract.rs) carry no file origin, so once merged we can't tell which file "caused" a given date's violation. Fix by filtering after the fact: for the openuri, get that file's own shard-local dates viafind_shard_by_set_dimension(std::slice::from_ref(root), TIMESHEET_DIMENSION_NAME)(alreadypub, already imported inlsp.rs) mapped through.moment.with_timezone(self.tz).date_naive()into aHashSet<NaiveDate>. Only emit a timesheet diagnostic (overlap or error) forts.dateif that date is in the open file's own date set. This means a violation shows up on every file that has an entry on the offending date (reasonable — the editor for any of those files should see it), but not on unrelated files.workspace_diagnostic(lsp.rs:921) currently only reports todos; optionally extend it the same way soworkspace/diagnosticpulls also surface R18 violations attributed per-file using the same date-membership filter — worth doing in this ticket since the plumbing is identical, but can be split into a follow-up if it makes the diff too large.did_changediagnostic computation walk+cache the whole base folder instead of one file.file_cachealready avoids re-parsing unchanged files (only invalidated viadid_change_watched_files,lsp.rs:719, and on open/change of the file itself), so steady-state cost is one directory walk (list_markdown_files) plus aDashMaplookup per file — acceptable for typical stream-note folder sizes, no further caching needed for this ticket.Testing
lsp.rs's existingmod tests(lsp.rs:1153), following thetempdir-based pattern already used forlist_markdown_filestests (lsp.rs:1320etc.): create 2+.mdfiles in a tempdir whose combined@Timesheetmarkers span midday without a closing break only when merged, assertcompute_diagnosticson file A (which alone looks fine) now reports the error once file B exists, and that neither file reports it once B supplies the closing break.publish_diagnosticsis skipped whensupports_pull_diagnosticsis true, and still fires when a client omits that capability (back-compat for push-only clients).Scope check
All three fixes stay within
src/cli/commands/lsp.rs(plus reading the already-pubTIMESHEET_DIMENSION_NAME/find_shard_by_set_dimension); no changes needed tosrc/timesheet/orsrc/error.rs.REQUIREMENTS.md/README.mdshould get a short note under R18 that timesheet diagnostics are repository-wide, per project convention of keeping the spec in sync with behavior.Implemented in PR #137 (branch
136_lsp-timesheet-diagnostics-fixes).Summary of findings:
textDocument.diagnosticcapability), and cross-file timesheet merging (pre-filtered to files sharing a date with the open document, so an unrelated file'sTimesheetError— which aborts the wholeextract_timesheetscall — can never leak onto this file's diagnostics).@Timesheetcard logged for later today (before that time arrives) produced an inverted timecard (from 12:00 to 11:00) because the "close at now" synthetic-close fallback inaggregate_timecard_daynever checked thatnowwas actually after the card's start time. Fixed by clamping to a zero-duration timecard, per the reporter's preference. This was bundled into the same branch/PR rather than a separate ticket.REQUIREMENTS.md(R18, R25b) updated to document both the cross-file validation behavior and the today-only synthetic-close clamping.nix flake check.