diff --git a/docs/plans/012-render-ordered-preview-diffs.md b/docs/plans/012-render-ordered-preview-diffs.md index 2810832..1ead2b2 100644 --- a/docs/plans/012-render-ordered-preview-diffs.md +++ b/docs/plans/012-render-ordered-preview-diffs.md @@ -6,7 +6,7 @@ ## Status -- **Status:** TODO +- **Status:** DONE — six regressions and all acceptance gates pass - **Priority:** P2 - **Effort:** M - **Risk:** MED @@ -188,13 +188,27 @@ The six required tests are the three initial rendering regressions, `ordered_pre ## Done criteria -- [ ] All six named tests are listed and pass; the original regressions fail before the fix. -- [ ] Create→edit and rename→edit produce truthful nonempty diffs before any physical Application. -- [ ] Repeated text operations render against their own virtual before-state, and projected after-bytes equal staged after-bytes. -- [ ] One shared ordered evaluator powers presentation and staging; no third interpreter exists. -- [ ] Stale/malformed input cannot fabricate a diff, and rendering does not write Workspace files or transaction artifacts. -- [ ] Full tests, Clippy, format, schema, fixture, and diff checks exit 0. -- [ ] Only scoped files changed and index row 012 is updated. +- [x] All six named tests are listed and pass; the original regressions fail before the fix. +- [x] Create→edit and rename→edit produce truthful nonempty diffs before any physical Application. +- [x] Repeated text operations render against their own virtual before-state, and projected after-bytes equal staged after-bytes. +- [x] One shared ordered evaluator powers presentation and staging; no third interpreter exists. +- [x] Stale/malformed input cannot fabricate a diff, and rendering does not write Workspace files or transaction artifacts. +- [x] Full tests, Clippy, format, schema, fixture, and diff checks exit 0. +- [x] Only scoped files changed; index row 012 is coordinated by the root reviewer. + +## Implementation and verification evidence + +- Starting HEAD `e83786f` (merged PR #59), initially clean. Reviewed drift from `5268c6a`: prior plans added directory-membership certificates, reservation ownership, and proven commit/rollback effects. These are preserved; the ordered staging interpreter itself still matched this plan's excerpt. No unexplained semantic drift. +- Initial valid RED: `cargo test --locked --bin lspctl ordered_preview` failed the create/repeated presentation assertions (`None`), and rename→edit failed opening the nonexistent virtual destination at operation 1. Unicode/CRLF WorkspaceEdits compiled and passed validation up to those intended failures. The planner regression independently failed the same missing-source path. Logs: `/tmp/48-render-red.log`, `/tmp/48-planner-red.log`. +- `ResourceState.text_source` is in-memory only. Physical file inspection initializes it; moved file/directory states retain it; empty creates and tombstones clear it. Lazy reads retain ancestor/no-follow checks and per-Document limits. The required planner test covers file, nested directory, and both chained rename forms, plus deletion non-resurrection and size rejection. +- `visit_canonical_text_outputs` is the single ordered evaluator shared by staging and presentation. It retains digest checks, virtual text/rename aliases/missing roots, cumulative staged-byte limits, and the existing access-time-preserving reader. The staging sink retains exclusive private output creation, permissions, flushes, and operation-index filenames. The presentation sink emits ordered resource notices and contextual radius-3 hunks without staging artifacts. Canonical byte conversions, ordering, bounds, and UTF-8 boundaries fail rather than panic. +- Six named `ordered_preview_*` tests pass. Equivalence covers create→edit, rename→edit, repeated edits, directory rename→nested edit, overwrite-create→edit, and delete→create→edit, comparing projected bytes and canonical digests to real staged files. Tests separately assert exact display lines/order, unchanged physical manifests, ownership marker/private Unix permissions, and cumulative staging/display limits. Stale/missing input, malformed ranges/digests, no-ops, and binary-only resource notices are covered without Workspace writes. +- Scoped GREEN on Linux: discovery lists all **6** required names; `ordered_preview` **6**, `mutation::` **44**, planner **9**, and existing private-staging exemplar **1** pass. Clippy with `-D warnings`, formatting, schema, immutable stored-state, and whitespace checks exit **0**. Logs: `/tmp/48-discovery-green.log`, `/tmp/48-step4.log`, `/tmp/48-mutation.log`, `/tmp/48-planner-all.log`, `/tmp/48-staging.log`, `/tmp/48-clippy.log`, `/tmp/48-format.log`, `/tmp/48-schema.log`, `/tmp/48-state.log`. +- Root independently reviewed the production and test diffs without blocking findings. Native path audit confirmed these tests share the planner's lexical root/file-URI convention (no Owner snapshot canonicalization mismatch). Windows/macOS/MSRV have not been run locally. +- Combined full GREEN after #49 completed its intended RED phase: `cargo test --locked --all-targets --features fake-server` passes **152** tests (104 unit + 4 CLI + 3 fake-server fixture + 38 lifecycle + 3 installation), including all six #48 regressions and existing contextual-diff/Mutation coverage. Evidence: `/tmp/49-full-green.log`, independently inspected. Combined Clippy, formatting, schema, immutable stored-state, and whitespace gates also pass. +- Only the three scoped Mutation files and this plan were changed for #48; the coordinator owns the index. Concurrent #49 changes are separately scoped. Changes remain uncommitted. + +- Final coordinator verification after all three plans: **155** tests pass (107 unit + 4 CLI + 3 documentation + 38 lifecycle + 3 installation), exact regression discovery counts **6/2/5/3**, and Clippy, formatting, schema, stored-state, and whitespace gates pass. Logs: `/tmp/48-50-root-full-green.log`, `/tmp/48-50-root-clippy.log`. Independent coordinator review found no blocking findings; the shared index now marks plans 012–014 DONE. Changes remain uncommitted; native Windows/macOS/MSRV CI was not run. ## STOP conditions diff --git a/docs/plans/013-persist-related-diagnostic-reports.md b/docs/plans/013-persist-related-diagnostic-reports.md index 3300f91..b4c61df 100644 --- a/docs/plans/013-persist-related-diagnostic-reports.md +++ b/docs/plans/013-persist-related-diagnostic-reports.md @@ -7,7 +7,7 @@ ## Status -- **Status:** TODO +- **Status:** DONE — seven regressions and all local acceptance gates pass - **Finding:** Audit #13 - **Priority:** P2 - **Effort:** M @@ -184,16 +184,29 @@ The two cache tests protect storage semantics and bounds; the five integration t ## Done criteria -- [ ] All seven named regression tests are discovered and pass (two unit, five integration). -- [ ] Uncached unchanged children reject the entire cache update; result-ID growth cannot cause mid-response reconstruction failure. -- [ ] Workspace-only partials persist; malformed, errored, and cancelled results leave accepted cached state intact. -- [ ] Three transient CLI calls reuse the same Owner and reconstruct full related reports, including partial-only input. -- [ ] Third-call fixture assertions prove previous-result IDs survive export/import updates. -- [ ] Cache-limit tests assert both snapshot count and serialized byte bounds; no evicted entry is revived by import. -- [ ] Raw Query output and diagnostic `rawReport` evidence retain their original semantics. -- [ ] Full test/build, lint, format, schema, stored-state, and whitespace gates exit 0. -- [ ] Only scoped implementation/metadata files changed; temporary Owners cleaned up. -- [ ] Plan and index status/evidence updated. +- [x] All seven named regression tests are discovered and pass (two unit, five integration). +- [x] Uncached unchanged children reject the entire cache update; result-ID growth cannot cause mid-response reconstruction failure. +- [x] Workspace-only partials persist; malformed, errored, and cancelled results leave accepted cached state intact. +- [x] Three transient CLI calls reuse the same Owner and reconstruct full related reports, including partial-only input. +- [x] Third-call fixture assertions prove previous-result IDs survive export/import updates. +- [x] Cache-limit tests assert both snapshot count and serialized byte bounds; no evicted entry is revived by import. +- [x] Raw Query output and diagnostic `rawReport` evidence retain their original semantics. +- [x] Full test/build, lint, format, schema, stored-state, and whitespace gates exit 0. +- [x] Only scoped implementation/metadata files changed; temporary Owners cleaned up. +- [x] Plan and index status/evidence updated. + +## Verification evidence (2026-09-08) + +- Starting HEAD: `e83786f` (merged PR #59), detached at `origin/main`, clean worktree. #40 is closed. Drift review covered the landed complete-chunk change plus explicit-server selection, bounded handshakes/writes, and synchronization delivery; those invariants remain intact. `ActiveQuery.partial_chunks` retains whole progress values, while `partial_result_items` alone supplies the existing flattened failure evidence. Baseline full suite passed **139** tests (`/tmp/49-baseline.log`). +- Cache RED: both required unit tests failed on the original implementation: export/import reverted `main-two` to `main-one`, and an unresolved related report yielded non-null effective data instead of rejecting the update (`/tmp/49-cache-red.log`). An initial Rust sum type-inference typo was fixed before recording this intended RED. +- CLI RED: final-only and partial-only related reports both passed the first full response and failed the second independent CLI invocation with `invalid_server_result` at the related URI; Workspace follow-up lacked the partial-only record's previous ID (`/tmp/49-cli-red.log`). A relative test `--file` argument was corrected before this evidence. Malformed coverage uses an invalid report shape (`items: 17`), not deeper Diagnostic validation outside this plan. +- The Owner now merges complete chunks and validates cloned diagnostic values through the existing Query helpers only after successful post-response Document validation. It never rewrites final results or raw partial evidence. Cache reconstruction reads all prerequisites before any bounded store, so unresolved children reject the whole update and ID-growth eviction cannot interrupt current-response reconstruction. Pull cache payloads retain effective full content/latest IDs; import remains independent-record-only and published records are unchanged. +- Caller audit: `dispatch_owner_query` imports independent Owner state before composing diagnostic requests (read-only `session.rs`); document/workspace envelopes retain their existing invalid-result checks and exact current raw metadata. Owner successful responses call the shared merger/validator for only the two diagnostic methods, and errored/cancelled/failed-validation paths do not update the pull cache. `apply_pull_report` remains the independent import path; document/workspace methods reconstruct all accepted reports before storing. `store`, result-ID invalidation, and byte accounting remain the existing bounded mechanism. +- Discovery lists exactly **two** unit and **five** integration `related_diagnostics` tests. All seven pass, all six diagnostics unit tests pass, and `named_cardinality_and_invalid_results_are_normalized` passes. The five integration tests also passed three repeated runs (**15/15**). Fixtures assert actual latest previous IDs on the third process invocation, stable Owner generations, final/partial/Workspace reconstruction, malformed/unresolved/error/cancel rejection, and exact raw results/progress traces (`/tmp/49-cli-green.log`). Cancellation uses the existing partial-byte-limit/acknowledgement gate, not a sleep race. +- Full suite passed **152** tests with concurrent plan 012 changes present (104 unit + 4 CLI + 3 documentation + 38 lifecycle + 3 installation; `/tmp/49-full-green.log`). Clippy with `-D warnings`, formatting, schema, stored-state, and `git diff --check` all exit **0**. Tests ran locally on Linux; native Windows/macOS and MSRV were not run locally. +- Issue #49 changes are limited to its five allowed implementation files and this plan. Other worktree changes belong to the separately authorized plans 012/014; the root coordinator owns their shared index update and final combined review. No untracked files, dependency/schema changes, commit, push, or PR introduced by this task. Temporary fixtures stop only their own Owners, including panic cleanup. + +- Final coordinator verification after all three plans: **155** tests pass (107 unit + 4 CLI + 3 documentation + 38 lifecycle + 3 installation), exact regression discovery counts **6/2/5/3**, and Clippy, formatting, schema, stored-state, and whitespace gates pass. Logs: `/tmp/48-50-root-full-green.log`, `/tmp/48-50-root-clippy.log`. Independent coordinator review found no blocking findings; the shared index now marks plans 012–014 DONE. Changes remain uncommitted; native Windows/macOS/MSRV CI was not run. ## STOP conditions diff --git a/docs/plans/014-correct-recursive-glob-boundaries.md b/docs/plans/014-correct-recursive-glob-boundaries.md index 6dbf477..010f551 100644 --- a/docs/plans/014-correct-recursive-glob-boundaries.md +++ b/docs/plans/014-correct-recursive-glob-boundaries.md @@ -7,7 +7,7 @@ ## Status -- **Status:** TODO +- **Status:** DONE — three regressions and all acceptance gates pass - **Audit finding:** 14 - **Priority:** P2 - **Effort:** S @@ -126,12 +126,23 @@ Three narrowly scoped unit tests cover the algorithm and both independent consum ## Done criteria -- [ ] Discovery assertion returns 0 with exactly three new `recursive_glob_*` tests. -- [ ] `**/main.rs` does not match `/workspace/domain.rs`, while all listed component-boundary positives pass. -- [ ] Document selector and file-operation consumer regressions pass. -- [ ] Existing Query/Owner tests, full suite, Clippy, formatting, and diff hygiene all pass. -- [ ] Production Owner runtime, schemas, dependencies, and unrelated glob semantics are untouched. -- [ ] This plan and its index row record completion. +- [x] Discovery assertion returns 0 with exactly three new `recursive_glob_*` tests. +- [x] `**/main.rs` does not match `/workspace/domain.rs`, while all listed component-boundary positives pass. +- [x] Document selector and file-operation consumer regressions pass. +- [x] Existing Query/Owner tests, full suite, Clippy, formatting, and diff hygiene all pass. +- [x] Production Owner runtime, schemas, dependencies, and unrelated glob semantics are untouched by this plan. +- [x] This plan records completion; the coordinating reviewer owns the index update. + +## Verification evidence + +- Starting HEAD: `e83786f` (detached `origin/main`). Plans 012/013 were concurrently authorized uncommitted work and were preserved. Drift inspection against `5268c6a` found no Query matcher changes; Owner changes belong to landed lifecycle/synchronization plans and retain the same file-operation filter contract. Plan 013's concurrent changes only widen two unrelated Query helper visibilities and update diagnostic caching in the Owner. +- Added exactly the three specified regressions. Discovery assertion exits **0** (`/tmp/50-discovery.log`). The initial focused run failed all three on the intended `domain.rs` admission: direct matcher returned true, Document composition admitted the suffix, and rename filtering admitted `domain.rs -> other.rs` (`/tmp/50-red.log`). No fixture or compilation failure was counted as RED. +- The only production change for this plan gates the `**/` suffix attempt on the beginning of the path or a preceding `/`. Recursive consumption and the memoization key are unchanged. Table coverage preserves zero/multiple directories, ordinary `*`, bare `**`, braces/classes, and ASCII case handling. Consumer tests cover absolute/relative selectors, either rename endpoint, and create filters with resource-kind and case options. +- Focused regressions: **3** pass (`/tmp/50-green.log`); Query tests: **18** pass (`/tmp/50-query.log`); Owner unit tests: **7** pass (`/tmp/50-owner.log`). +- Full `cargo test --locked --all-targets --features fake-server`: **155** pass (107 unit + 4 CLI + 3 documentation + 38 lifecycle + 3 installation), including concurrent plans 012/013 (`/tmp/50-full-green.log`). Clippy with `-D warnings`, formatting, discovery assertion, and `git diff --check` all exit **0** (`/tmp/50-clippy.log`). +- Scope inspection confirms this plan changes only the shared Query matcher, its two inline tests, the Owner test module, and this metadata. No dependencies, schemas, normalization, or new glob semantics were introduced. Verified locally on Linux Rust **1.98.1**; native macOS/Windows and Rust 1.89 were not run locally. Changes remain uncommitted for review. + +- Final coordinator verification after all three plans: **155** tests pass (107 unit + 4 CLI + 3 documentation + 38 lifecycle + 3 installation), exact regression discovery counts **6/2/5/3**, and Clippy, formatting, schema, stored-state, and whitespace gates pass. Logs: `/tmp/48-50-root-full-green.log`, `/tmp/48-50-root-clippy.log`. Independent coordinator review found no blocking findings; the shared index now marks plans 012–014 DONE. Changes remain uncommitted; native Windows/macOS/MSRV CI was not run. ## STOP conditions diff --git a/docs/plans/README.md b/docs/plans/README.md index 390cdac..704611a 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -23,9 +23,9 @@ Effort: **S** hours, **M** roughly a day, **L** multiple days, including tests. | 9 | [009](009-decouple-trust-administration.md) | Decouple Trust administration from unrelated executable availability | P1 | M | MED | — | DONE — bounded fix; 129 tests/gates passed; independent named-status success deferred | | 10 | [011](011-bound-language-server-writes.md) | Bound language-server writes and make incomplete delivery fatal | P1 | M | MED | 005 | DONE — 3 transport/4 lifecycle regressions; 136 tests and all gates passed | | 11 | [010](010-deliver-document-synchronization-transitions.md) | Deliver committed Document synchronization transitions | P1 | M | MED | 011 | DONE — 3 lifecycle regressions; 139 tests and all gates passed | -| 12 | [012](012-render-ordered-preview-diffs.md) | Render Preview diffs against ordered virtual filesystem state | P2 | M | MED | — | TODO | -| 13 | [013](013-persist-related-diagnostic-reports.md) | Persist related diagnostic reports across CLI calls | P2 | M | MED | 004 | TODO | -| 14 | [014](014-correct-recursive-glob-boundaries.md) | Respect directory boundaries in recursive globs | P2 | S | LOW | — | TODO | +| 12 | [012](012-render-ordered-preview-diffs.md) | Render Preview diffs against ordered virtual filesystem state | P2 | M | MED | — | DONE — 6 regressions; 155 combined tests and all gates passed | +| 13 | [013](013-persist-related-diagnostic-reports.md) | Persist related diagnostic reports across CLI calls | P2 | M | MED | 004 | DONE — 2 cache/5 lifecycle regressions; 155 combined tests and all gates passed | +| 14 | [014](014-correct-recursive-glob-boundaries.md) | Respect directory boundaries in recursive globs | P2 | S | LOW | — | DONE — 3 regressions; 155 combined tests and all gates passed | Status values: **TODO**, **IN PROGRESS**, **DONE**, **BLOCKED** (add one-line cause), **REJECTED** (add rationale, such as independently fixed), or **STALE** (requires plan refresh). A green test filter matching zero tests is not evidence of completion. Record actual command results in the individual plan before marking DONE. Native platform verification still belongs in CI when unavailable locally. diff --git a/src/mutation.rs b/src/mutation.rs index 1181b01..98876db 100644 --- a/src/mutation.rs +++ b/src/mutation.rs @@ -1014,48 +1014,48 @@ fn refresh_preview_presentation( }; stored.preview.stale_reasons = preview_manifest_mismatches(&stored.preview.plan, ¤t); stored.preview.diff = if stored.preview.stale_reasons.is_empty() { - preview_diff(stored) + preview_diff(stored, mutation_settings) } else { None }; } -fn preview_diff(stored: &StoredPreview) -> Option { +fn preview_diff(stored: &StoredPreview, limits: &MutationSettings) -> Option { let mut output = String::new(); - for operation in &stored.preview.plan.operations { - match operation { - CanonicalOperation::Text { path, edits, .. } => { - let bytes = fs::read(path).ok()?; - let old = std::str::from_utf8(&bytes).ok()?; - let mut new = String::new(); - let mut cursor = 0; - for edit in edits { - let start = usize::try_from(edit.start_byte).ok()?; - let end = usize::try_from(edit.end_byte).ok()?; - new.push_str(old.get(cursor..start)?); - new.push_str(&edit.new_text); - cursor = end; + application::visit_canonical_text_outputs( + &stored.preview.preview_id, + &stored.preview.plan.operations, + limits, + |operation, text| { + match operation { + CanonicalOperation::Text { path, .. } => { + let (before, after) = text.unwrap(); + let decode = |bytes| { + std::str::from_utf8(bytes) + .map_err(|_| invalid_workspace_edit(&stored.preview.edit, Vec::new())) + }; + output.push_str(&contextual_text_diff(path, decode(before)?, decode(after)?)); + } + CanonicalOperation::Create { path, .. } => { + output.push_str(&format!("create {}\n", path.display())); + } + CanonicalOperation::Rename { + old_path, new_path, .. + } => { + output.push_str(&format!( + "rename {} -> {}\n", + old_path.display(), + new_path.display() + )); + } + CanonicalOperation::Delete { path, .. } => { + output.push_str(&format!("delete {}\n", path.display())); } - new.push_str(old.get(cursor..)?); - output.push_str(&contextual_text_diff(path, old, &new)); - } - CanonicalOperation::Create { path, .. } => { - output.push_str(&format!("create {}\n", path.display())); - } - CanonicalOperation::Rename { - old_path, new_path, .. - } => { - output.push_str(&format!( - "rename {} -> {}\n", - old_path.display(), - new_path.display() - )); - } - CanonicalOperation::Delete { path, .. } => { - output.push_str(&format!("delete {}\n", path.display())); } - } - } + Ok(()) + }, + ) + .ok()?; Some(output) } @@ -1271,3 +1271,261 @@ pub(crate) fn create_preview_record( reserved: false, } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + use tempfile::TempDir; + + pub(super) fn text_change(path: &Path, start: u32, end: u32, text: &str) -> Value { + json!({"textDocument": {"uri": url::Url::from_file_path(path).unwrap(), "version": null}, + "edits": [{"range": {"start": {"line": 0, "character": start}, + "end": {"line": 0, "character": end}}, "newText": text}]}) + } + + pub(super) fn stored_preview(workspace: &Path, edit: Value) -> StoredPreview { + let (previews, _, mutation) = default_mutation_settings(); + let planner = + WorkspaceEditPlanner::open(workspace, PositionEncoding::Utf16, &previews, &mutation) + .unwrap(); + let planned = planner.plan_workspace_edit(&edit).unwrap(); + let workspace_uri = url::Url::from_directory_path(workspace) + .unwrap() + .to_string(); + StoredPreview { + format_version: state::MUTATION_STATE_VERSION, + created_unix_seconds: 0, + expires_unix_seconds: i64::MAX, + workspace_path: workspace.to_path_buf(), + authorization_digest: String::new(), + recovery_manifest_digest: None, + preview: create_preview_record( + PreviewRecordContext { + preview_id: "prv_00000000000000000000000000000000", + workspace_uri: &workspace_uri, + server: None, + session_identity: "test", + position_encoding: "utf-16", + source: json!({}), + edit, + command: None, + }, + planned, + ), + } + } + + #[test] + fn ordered_preview_create_then_edit() { + let workspace = TempDir::new().unwrap(); + let file = workspace.path().join("created.rs"); + let stored = stored_preview( + workspace.path(), + json!({"documentChanges": [ + {"kind": "create", "uri": url::Url::from_file_path(&file).unwrap()}, + text_change(&file, 0, 0, "é🦀\r\n") + ]}), + ); + let diff = preview_diff(&stored, &default_mutation_settings().2) + .expect("create then edit must render"); + assert_eq!( + diff, + format!( + "create {}\n{}", + file.display(), + contextual_text_diff(&file, "", "é🦀\r\n") + ) + ); + assert!(diff.contains("+é🦀\r\n"), "{diff:?}"); + assert!(!file.exists()); + } + + #[test] + fn ordered_preview_repeated_text_edits() { + let workspace = TempDir::new().unwrap(); + let file = workspace.path().join("repeated.rs"); + fs::write(&file, "é\r\n").unwrap(); + let stored = stored_preview( + workspace.path(), + json!({"documentChanges": [ + text_change(&file, 1, 1, "🦀long"), text_change(&file, 3, 7, "new") + ]}), + ); + let diff = preview_diff(&stored, &default_mutation_settings().2) + .expect("repeated edits must render intermediate text"); + assert_eq!( + diff, + format!( + "{}{}", + contextual_text_diff(&file, "é\r\n", "é🦀long\r\n"), + contextual_text_diff(&file, "é🦀long\r\n", "é🦀new\r\n") + ) + ); + for line in ["-é\r\n", "+é🦀long\r\n", "-é🦀long\r\n", "+é🦀new\r\n"] { + assert!(diff.contains(line), "missing {line:?}: {diff:?}"); + } + assert_eq!(fs::read(&file).unwrap(), "é\r\n".as_bytes()); + } + + #[test] + fn ordered_preview_preserves_stale_and_noop_behavior() { + use crate::canonical_value::digest_raw_bytes; + + let workspace = TempDir::new().unwrap(); + let file = workspace.path().join("text.rs"); + let original = "é🦀old\r\n"; + fs::write(&file, original).unwrap(); + let (previews, _, limits) = default_mutation_settings(); + let stored = stored_preview( + workspace.path(), + json!({"documentChanges": [text_change(&file, 3, 6, "new")]}), + ); + let noop = stored_preview( + workspace.path(), + json!({"documentChanges": [text_change(&file, 3, 6, "old")]}), + ); + assert!(noop.preview.plan.operations.is_empty()); + assert_eq!(preview_diff(&noop, &limits), Some(String::new())); + for invalid in [ + "before_digest", + "after_digest", + "reverse", + "outside", + "overflow", + "overlap", + "utf8_boundary", + ] { + let mut malformed = stored.clone(); + let CanonicalOperation::Text { + before_digest, + after_digest, + edits, + .. + } = &mut malformed.preview.plan.operations[0] + else { + unreachable!() + }; + match invalid { + "before_digest" => *before_digest = digest_raw_bytes(b"different"), + "after_digest" => *after_digest = digest_raw_bytes(b"different"), + "reverse" => { + edits[0].start_byte = 8; + edits[0].end_byte = 2; + } + "outside" => edits[0].end_byte = 100, + "overflow" => { + edits[0].start_byte = u64::MAX; + edits[0].end_byte = u64::MAX; + } + "overlap" => edits.push(edits[0].clone()), + "utf8_boundary" => { + edits[0].start_byte = 1; + edits[0].end_byte = 2; + *after_digest = digest_raw_bytes( + &[&original.as_bytes()[..1], b"new", &original.as_bytes()[2..]].concat(), + ); + } + _ => unreachable!(), + } + let mut emitted = 0; + assert!( + application::visit_canonical_text_outputs( + "test", + &malformed.preview.plan.operations, + &limits, + |_, _| { + emitted += 1; + Ok(()) + } + ) + .is_err(), + "{invalid}" + ); + assert_eq!(emitted, 0, "{invalid}"); + assert!(preview_diff(&malformed, &limits).is_none(), "{invalid}"); + assert_eq!(fs::read(&file).unwrap(), original.as_bytes()); + } + let mut stale = stored.clone(); + fs::write(&file, "external edit\n").unwrap(); + assert!( + preview_diff(&stale, &limits).is_none(), + "digest guards an intervening edit even without preflight" + ); + refresh_preview_presentation(&mut stale, &previews, &limits); + assert!(stale.preview.diff.is_none()); + assert!(!stale.preview.stale_reasons.is_empty()); + assert_eq!( + serde_json::to_value(&stale.preview.plan).unwrap(), + serde_json::to_value(&stored.preview.plan).unwrap() + ); + assert_eq!(fs::read(&file).unwrap(), b"external edit\n"); + fs::remove_file(&file).unwrap(); + assert!(preview_diff(&stored, &limits).is_none()); + refresh_preview_presentation(&mut stale, &previews, &limits); + assert!(stale.preview.diff.is_none()); + assert!(!file.exists()); + + // Resource-only operations must not decode binary files or materialize their destinations. + let binary = workspace.path().join("binary"); + let moved = workspace.path().join("moved"); + let created = workspace.path().join("created"); + fs::write(&binary, [0xff, 0, 0xfe]).unwrap(); + let uri = |path: &Path| url::Url::from_file_path(path).unwrap(); + let resources = stored_preview( + workspace.path(), + json!({"documentChanges": [ + {"kind": "rename", "oldUri": uri(&binary), "newUri": uri(&moved)}, + {"kind": "delete", "uri": uri(&moved)}, + {"kind": "create", "uri": uri(&created)} + ]}), + ); + let mut zero = limits.clone(); + zero.max_staged_text_bytes = 0; + assert_eq!( + preview_diff(&resources, &zero), + Some(format!( + "rename {} -> {}\ndelete {}\ncreate {}\n", + binary.display(), + moved.display(), + moved.display(), + created.display() + )) + ); + assert_eq!(fs::read(&binary).unwrap(), [0xff, 0, 0xfe]); + assert!(!moved.exists()); + assert!(!created.exists()); + assert_eq!(fs::read_dir(workspace.path()).unwrap().count(), 1); + } + + #[test] + fn ordered_preview_rename_then_edit() { + let workspace = TempDir::new().unwrap(); + let source = workspace.path().join("source.rs"); + let target = workspace.path().join("target.rs"); + fs::write(&source, "é🦀old\r\n").unwrap(); + let stored = stored_preview( + workspace.path(), + json!({"documentChanges": [ + {"kind": "rename", "oldUri": url::Url::from_file_path(&source).unwrap(), + "newUri": url::Url::from_file_path(&target).unwrap()}, + text_change(&target, 3, 6, "new") + ]}), + ); + let diff = preview_diff(&stored, &default_mutation_settings().2) + .expect("rename then edit must render"); + assert_eq!( + diff, + format!( + "rename {} -> {}\n{}", + source.display(), + target.display(), + contextual_text_diff(&target, "é🦀old\r\n", "é🦀new\r\n") + ) + ); + assert!(diff.contains("-é🦀old\r\n")); + assert!(diff.contains("+é🦀new\r\n")); + assert_eq!(fs::read(&source).unwrap(), "é🦀old\r\n".as_bytes()); + assert!(!target.exists()); + } +} diff --git a/src/mutation/application.rs b/src/mutation/application.rs index c15cdaf..c559c60 100644 --- a/src/mutation/application.rs +++ b/src/mutation/application.rs @@ -1830,6 +1830,56 @@ fn stage_text_outputs( transaction: &TransactionRecord, operations: &[CanonicalOperation], limits: &MutationSettings, +) -> Result<(), ContractFailure> { + visit_canonical_text_outputs( + &transaction.transaction_id, + operations, + limits, + |operation, text| { + let Some((_, after)) = text else { + return Ok(()); + }; + let staged_path = + staged_text_path(&transaction.artifact_directory, operation_index(operation)); + let mut staged = OpenOptions::new() + .write(true) + .create_new(true) + .open(&staged_path) + .map_err(|error| { + stage_failure( + &transaction.transaction_id, + "A text output cannot be created in staging.", + error.raw_os_error(), + ) + })?; + state_permissions::restrict_file(&staged_path).map_err(|error| { + stage_failure( + &transaction.transaction_id, + "A staged text output cannot be made private.", + error.raw_os_error(), + ) + })?; + staged + .write_all(after) + .and_then(|()| staged.sync_all()) + .map_err(|error| { + stage_failure( + &transaction.transaction_id, + "A staged text output cannot be flushed.", + error.raw_os_error(), + ) + }) + }, + ) +} + +/// Evaluates canonical operations once, in order, for staging and non-mutating presentation. +/// Text visits borrow the immediately preceding virtual bytes and their validated replacement. +pub(crate) fn visit_canonical_text_outputs( + context_id: &str, + operations: &[CanonicalOperation], + limits: &MutationSettings, + mut visit: impl FnMut(&CanonicalOperation, Option<(&[u8], &[u8])>) -> Result<(), ContractFailure>, ) -> Result<(), ContractFailure> { let mut texts = BTreeMap::>::new(); let mut unavailable = Vec::::new(); @@ -1848,23 +1898,23 @@ fn stage_text_outputs( let before = virtual_text(path, &texts, &unavailable, &aliases).map_err(|error| { stage_failure( - &transaction.transaction_id, + context_id, "A text input cannot be staged.", error.raw_os_error(), ) })?; if digest_raw_bytes(&before) != *before_digest { return Err(stage_failure( - &transaction.transaction_id, + context_id, "A staged text input no longer matches its canonical digest.", None, )); } let after = apply_canonical_text_edits(&before, edits) - .map_err(|reason| stage_failure(&transaction.transaction_id, &reason, None))?; + .map_err(|reason| stage_failure(context_id, &reason, None))?; if digest_raw_bytes(&after) != *after_digest { return Err(stage_failure( - &transaction.transaction_id, + context_id, "A staged text output does not match its canonical digest.", None, )); @@ -1887,35 +1937,7 @@ fn stage_text_outputs( }), }); } - let staged_path = staged_text_path(&transaction.artifact_directory, *index); - let mut staged = OpenOptions::new() - .write(true) - .create_new(true) - .open(&staged_path) - .map_err(|error| { - stage_failure( - &transaction.transaction_id, - "A text output cannot be created in staging.", - error.raw_os_error(), - ) - })?; - state_permissions::restrict_file(&staged_path).map_err(|error| { - stage_failure( - &transaction.transaction_id, - "A staged text output cannot be made private.", - error.raw_os_error(), - ) - })?; - staged - .write_all(&after) - .and_then(|()| staged.sync_all()) - .map_err(|error| { - stage_failure( - &transaction.transaction_id, - "A staged text output cannot be flushed.", - error.raw_os_error(), - ) - })?; + visit(operation, Some((&before, &after)))?; texts.insert(path.clone(), after); unavailable.retain(|root| root != path); } @@ -1952,6 +1974,9 @@ fn stage_text_outputs( unavailable.push(path.clone()); } } + if !matches!(operation, CanonicalOperation::Text { .. }) { + visit(operation, None)?; + } } Ok(()) } @@ -1999,12 +2024,20 @@ fn apply_canonical_text_edits( before: &[u8], edits: &[super::planner::CanonicalTextEdit], ) -> Result, String> { + let text = std::str::from_utf8(before) + .map_err(|_| "A canonical staged text input is not valid UTF-8.".to_owned())?; let mut after = Vec::with_capacity(before.len()); let mut cursor = 0; for edit in edits { - let start = edit.start_byte as usize; - let end = edit.end_byte as usize; - if start < cursor || end < start || end > before.len() { + let start = usize::try_from(edit.start_byte) + .map_err(|_| "A canonical staged text edit offset is too large.".to_owned())?; + let end = usize::try_from(edit.end_byte) + .map_err(|_| "A canonical staged text edit offset is too large.".to_owned())?; + if start < cursor + || end < start + || !text.is_char_boundary(start) + || !text.is_char_boundary(end) + { return Err("A canonical staged text edit range is invalid.".to_owned()); } after.extend_from_slice(&before[cursor..start]); @@ -4753,6 +4786,207 @@ mod tests { ); } + #[test] + fn ordered_preview_matches_staged_outputs() { + use super::super::tests::{stored_preview, text_change}; + + for case in [ + "create", + "rename", + "repeated", + "directory", + "overwrite", + "recreate", + ] { + let workspace = TempDir::new().unwrap(); + let state = TempDir::new().unwrap(); + let store = MutationStateStore::open_at(state.path().join("state")).unwrap(); + let source = workspace.path().join("source"); + let target = workspace.path().join("target"); + let uri = |path: &Path| url::Url::from_file_path(path).unwrap(); + let original = "é🦀old\r\n"; + let (file, edits, expected) = match case { + "create" => ( + target.clone(), + vec![ + json!({"kind": "create", "uri": uri(&target)}), + text_change(&target, 0, 0, original), + ], + vec![("", original)], + ), + "rename" => { + fs::write(&source, original).unwrap(); + ( + target.clone(), + vec![ + json!({"kind": "rename", "oldUri": uri(&source), "newUri": uri(&target)}), + text_change(&target, 3, 6, "new"), + ], + vec![(original, "é🦀new\r\n")], + ) + } + "directory" => { + fs::create_dir_all(source.join("nested")).unwrap(); + fs::write(source.join("nested/file"), original).unwrap(); + let nested = target.join("nested").join("file"); + ( + nested.clone(), + vec![ + json!({"kind": "rename", "oldUri": uri(&source), "newUri": uri(&target)}), + text_change(&nested, 3, 6, "new"), + ], + vec![(original, "é🦀new\r\n")], + ) + } + "repeated" => { + fs::write(&source, original).unwrap(); + ( + source.clone(), + vec![ + text_change(&source, 3, 6, "longer"), + text_change(&source, 7, 9, "est"), + ], + vec![ + (original, "é🦀longer\r\n"), + ("é🦀longer\r\n", "é🦀longest\r\n"), + ], + ) + } + "overwrite" | "recreate" => { + fs::write(&target, b"physical old contents\n").unwrap(); + let mut edits = Vec::new(); + if case == "recreate" { + edits.push(json!({"kind": "delete", "uri": uri(&target)})); + } + edits.push(json!({"kind": "create", "uri": uri(&target), "options": {"overwrite": true}})); + edits.push(text_change(&target, 0, 0, original)); + (target.clone(), edits, vec![("", original)]) + } + _ => unreachable!(), + }; + let edit = json!({"documentChanges": edits}); + let stored = stored_preview(workspace.path(), edit.clone()); + let (previews, _, mutation) = super::super::default_mutation_settings(); + let planner = WorkspaceEditPlanner::open( + workspace.path(), + PositionEncoding::Utf16, + &previews, + &mutation, + ) + .unwrap(); + let transaction = provenance_transaction(workspace.path(), &planner, edit); + let before = planner + .inspect_manifest(&stored.preview.plan.before_manifest) + .unwrap(); + let mut projected = Vec::new(); + let mut notices = String::new(); + visit_canonical_text_outputs( + "test", + &stored.preview.plan.operations, + &mutation, + |operation, text| { + if let Some((old, new)) = text { + let expected = expected[projected.len()]; + assert_eq!(old, expected.0.as_bytes(), "{case}"); + assert_eq!(new, expected.1.as_bytes(), "{case}"); + projected.push((operation_index(operation), new.to_vec())); + notices.push_str(&super::super::contextual_text_diff( + &file, expected.0, expected.1, + )); + } else { + match operation { + CanonicalOperation::Create { path, .. } => { + notices.push_str(&format!("create {}\n", path.display())) + } + CanonicalOperation::Rename { + old_path, new_path, .. + } => notices.push_str(&format!( + "rename {} -> {}\n", + old_path.display(), + new_path.display() + )), + CanonicalOperation::Delete { path, .. } => { + notices.push_str(&format!("delete {}\n", path.display())) + } + _ => unreachable!(), + } + } + Ok(()) + }, + ) + .unwrap(); + assert_eq!(projected.len(), expected.len(), "{case}"); + assert_eq!( + super::super::preview_diff(&stored, &mutation).unwrap(), + notices, + "{case}" + ); + for (old, new) in &expected { + if !old.is_empty() { + assert!(notices.contains(&format!("-{old}")), "{case}: {notices:?}"); + } + assert!(notices.contains(&format!("+{new}")), "{case}: {notices:?}"); + } + assert!(!transaction.artifact_directory.exists()); + assert!(store.list_transactions().unwrap().is_empty()); + assert_eq!( + planner + .inspect_manifest(&stored.preview.plan.before_manifest) + .unwrap(), + before + ); + stage_transaction(&transaction, &transaction.operations, &mutation).unwrap(); + for (index, bytes) in &projected { + let path = staged_text_path(&transaction.artifact_directory, *index); + let staged = fs::read(&path).unwrap(); + assert_eq!(&staged, bytes, "{case}"); + let CanonicalOperation::Text { after_digest, .. } = transaction + .operations + .iter() + .find(|operation| operation_index(operation) == *index) + .unwrap() + else { + unreachable!() + }; + assert_eq!(&digest_raw_bytes(&staged), after_digest, "{case}"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(fs::metadata(path).unwrap().permissions().mode() & 0o077, 0); + } + } + assert_eq!( + fs::read_to_string(transaction.artifact_directory.join(ARTIFACT_OWNER_FILE)) + .unwrap(), + transaction.transaction_id + ); + assert_eq!( + planner + .inspect_manifest(&stored.preview.plan.before_manifest) + .unwrap(), + before + ); + cleanup_transaction_artifacts(&transaction).unwrap(); + let mut limited = mutation.clone(); + limited.max_staged_text_bytes = projected + .iter() + .map(|(_, bytes)| bytes.len() as u64) + .sum::() + - 1; + assert!(super::super::preview_diff(&stored, &limited).is_none()); + let failure = + stage_transaction(&transaction, &transaction.operations, &limited).unwrap_err(); + assert_eq!(failure.failure.code, "resource_limit_exceeded"); + cleanup_transaction_artifacts(&transaction).unwrap(); + assert_eq!( + planner + .inspect_manifest(&stored.preview.plan.before_manifest) + .unwrap(), + before + ); + } + } + #[test] fn text_output_is_complete_private_staging_before_commit() { let workspace = TempDir::new().unwrap(); diff --git a/src/mutation/planner.rs b/src/mutation/planner.rs index d1e637b..894ac5e 100644 --- a/src/mutation/planner.rs +++ b/src/mutation/planner.rs @@ -187,6 +187,8 @@ pub(crate) struct WorkspaceEditPlanner<'a> { struct ResourceState { manifest: ManifestEntry, text: Option>, + // Physical source survives virtual renames until text is loaded. + text_source: Option, } #[derive(Default)] @@ -885,6 +887,7 @@ impl<'a> WorkspaceEditPlanner<'a> { next.manifest.resource_kind = ResourceKind::File; next.manifest.content_digest = Some(digest_raw_bytes(&[])); next.text = Some(Vec::new()); + next.text_source = None; workspace.entries.insert(path.clone(), next); workspace.affected.insert(path.clone()); summary.creates = summary.creates.saturating_add(1); @@ -1314,8 +1317,10 @@ impl<'a> WorkspaceEditPlanner<'a> { && workspace.entries[path].manifest.exists && workspace.entries[path].manifest.resource_kind == ResourceKind::File { + let source = workspace.entries[path].text_source.as_deref().unwrap(); + self.validate_existing_ancestors(source, index)?; let bytes = - read_text_file(path, self.preview_limits.max_document_text_bytes, index)?; + read_text_file(source, self.preview_limits.max_document_text_bytes, index)?; workspace.entries.get_mut(path).unwrap().text = Some(bytes); } return Ok(()); @@ -1560,6 +1565,7 @@ impl<'a> WorkspaceEditPlanner<'a> { metadata_digest: Some(metadata_digest(path, &metadata, index)?), }, text, + text_source: (resource_kind == ResourceKind::File).then(|| path.to_path_buf()), }) } @@ -1939,6 +1945,7 @@ fn missing_resource(path: &Path) -> ResourceState { ResourceState { manifest: missing_manifest(path), text: None, + text_source: None, } } @@ -2546,6 +2553,109 @@ mod tests { ) } + #[test] + fn ordered_preview_planner_tracks_renamed_text_sources() { + for (directory, chained) in [(false, false), (true, false), (false, true), (true, true)] { + let workspace = TempDir::new().unwrap(); + let source = workspace.path().join("source"); + let intermediate = workspace.path().join("intermediate"); + let destination = workspace.path().join("destination"); + let source_file = if directory { + fs::create_dir_all(source.join("nested")).unwrap(); + source.join("nested/text.txt") + } else { + source.clone() + }; + let destination_file = if directory { + destination.join("nested/text.txt") + } else { + destination.clone() + }; + let original = "a😀z\r\n"; + fs::write(&source_file, original).unwrap(); + let uri = |path: &Path| Url::from_file_path(path).unwrap(); + let rename = |from: &Path, to: &Path| json!({"kind": "rename", "oldUri": uri(from), "newUri": uri(to)}); + let mut operations = if chained { + vec![ + rename(&source, &intermediate), + rename(&intermediate, &destination), + ] + } else { + vec![rename(&source, &destination)] + }; + let text_edit = json!({"textDocument": {"uri": uri(&destination_file), "version": null}, + "edits": [{"range": {"start": {"line": 0, "character": 3}, + "end": {"line": 0, "character": 4}}, "newText": "long"}]}); + operations.push(text_edit.clone()); + let edit = json!({"documentChanges": operations}); + let (mut previews, mutation) = settings(); + let planned = WorkspaceEditPlanner::open( + workspace.path(), + PositionEncoding::Utf16, + &previews, + &mutation, + ) + .unwrap() + .plan_workspace_edit(&edit) + .unwrap(); + let CanonicalOperation::Text { + path, + before_digest, + after_digest, + edits, + .. + } = planned.plan.operations.last().unwrap() + else { + panic!("missing text operation") + }; + assert_eq!(path, &destination_file); + assert_eq!(before_digest, &digest_raw_bytes(original.as_bytes())); + assert_eq!(after_digest, &digest_raw_bytes("a😀long\r\n".as_bytes())); + assert_eq!((edits[0].start_byte, edits[0].end_byte), (5, 6)); + assert_eq!(fs::read(&source_file).unwrap(), original.as_bytes()); + assert!(!destination.exists()); + assert!(!intermediate.exists()); + + // A moved file still obeys the existing lazy read's per-Document bound. + previews.max_document_text_bytes = original.len() as u64 - 1; + let problems = WorkspaceEditPlanner::open( + workspace.path(), + PositionEncoding::Utf16, + &previews, + &mutation, + ) + .unwrap() + .plan_workspace_edit(&edit) + .unwrap_err(); + assert!( + problems + .iter() + .any(|problem| problem.code == "resource_limit_exceeded") + ); + previews.max_document_text_bytes = 1_000_000; + // Deleting the virtual destination must not reload its still-present physical source. + operations.pop(); + operations.push(json!({"kind": "delete", "uri": uri(&destination), + "options": {"recursive": true}})); + operations.push(text_edit); + let problems = WorkspaceEditPlanner::open( + workspace.path(), + PositionEncoding::Utf16, + &previews, + &mutation, + ) + .unwrap() + .plan_workspace_edit(&json!({"documentChanges": operations})) + .unwrap_err(); + assert!( + problems + .iter() + .any(|problem| problem.code == "unsupported_resource_kind") + ); + assert_eq!(fs::read(&source_file).unwrap(), original.as_bytes()); + } + } + #[test] fn directory_membership_covers_overwritten_destination() { let workspace = TempDir::new().unwrap(); diff --git a/src/query.rs b/src/query.rs index 059341c..4fb3a21 100644 --- a/src/query.rs +++ b/src/query.rs @@ -1078,7 +1078,8 @@ fn glob_matches_at( if recursive_directory { next_pattern += 1; } - glob_matches_at(pattern, path, next_pattern, path_index, states) + ((!recursive_directory || path_index == 0 || path[path_index - 1] == b'/') + && glob_matches_at(pattern, path, next_pattern, path_index, states)) || (path_index < path.len() && (recursive || path[path_index] != b'/') && glob_matches_at(pattern, path, pattern_index, path_index + 1, states)) @@ -1319,7 +1320,7 @@ fn invalid_json_input( } } -fn merge_partial_results( +pub(crate) fn merge_partial_results( command: QueryCommand, mut result: Value, partials: Vec, @@ -1434,7 +1435,7 @@ fn take_array_or_null( } } -fn normalize_named_result( +pub(crate) fn normalize_named_result( command: QueryCommand, mut result: Value, ) -> Result { @@ -2296,6 +2297,85 @@ mod tests { ); } + #[test] + fn recursive_glob_respects_directory_boundaries() { + for (pattern, path, ignore_case, expected) in [ + ("**/main.rs", "main.rs", false, true), + ("**/main.rs", "/main.rs", false, true), + ("**/main.rs", "src/main.rs", false, true), + ("**/main.rs", "/workspace/src/main.rs", false, true), + ("**/main.rs", "domain.rs", false, false), + ("**/main.rs", "/workspace/domain.rs", false, false), + ("**/main.rs", "/workspace/main.rs.bak", false, false), + ("src/**/main.rs", "src/main.rs", false, true), + ("src/**/main.rs", "src/one/two/main.rs", false, true), + ("src/**/main.rs", "src/domain.rs", false, false), + ("*.rs", "main.rs", false, true), + ("*.rs", "src/main.rs", false, false), + ("**.rs", "src/main.rs", false, true), + ("**main.rs", "src/domain.rs", false, true), + ("**/main.{rs,py}", "src/main.py", false, true), + ("**/main.{rs,py}", "src/domain.rs", false, false), + ("**/main.[r-t][!x]", "src/main.rs", false, true), + ("**/main.[!r]s", "src/main.rs", false, false), + ("**/MAIN.RS", "src/main.rs", true, true), + ("**/MAIN.RS", "src/main.rs", false, false), + ("**/MAIN.RS", "src/DOMAIN.RS", true, false), + ] { + assert_eq!( + protocol_glob_matches(pattern, path, ignore_case), + expected, + "pattern={pattern:?}, path={path:?}, ignore_case={ignore_case}" + ); + } + } + + #[test] + fn recursive_glob_document_selectors_reject_filename_suffixes() { + let invocation = ParsedInvocation { + command: vec!["definition".into()], + options: BTreeMap::from([ + ("--line".into(), vec!["0".into()]), + ("--column".into(), vec!["0".into()]), + ]), + positionals: Vec::new(), + }; + let mut document = document(); + for pattern in [ + json!("**/main.rs"), + json!({"baseUri": "file:///workspace", "pattern": "**/main.rs"}), + ] { + let mut capabilities = supported_with_options("definition", json!({})); + capabilities + .providers + .get_mut("definition") + .unwrap() + .selector = + Some(json!([{"language": "rust", "scheme": "file", "pattern": pattern}])); + for (name, expected) in [("main.rs", true), ("domain.rs", false)] { + document.uri = format!("file:///workspace/{name}"); + let result = compose( + &invocation, + Some(&document), + PositionEncoding::Utf16, + &capabilities, + None, + ); + if expected { + assert!(result.is_ok(), "{pattern:?}: {name}: {result:?}"); + } else { + assert_eq!( + result + .expect_err("filename suffix must not admit a Document") + .code, + "capability_unavailable", + "{pattern:?}: {name}" + ); + } + } + } + } + #[test] fn capability_gates_subfeatures_commands_and_document_selectors() { let document = document(); diff --git a/src/session/owner_runtime.rs b/src/session/owner_runtime.rs index f2d37ff..ce93dcf 100644 --- a/src/session/owner_runtime.rs +++ b/src/session/owner_runtime.rs @@ -1027,7 +1027,6 @@ impl LspRuntime { OwnerResponse::failure(owner_generation, failure) } else if message.get("result").is_some() { let result = message.get("result").cloned().unwrap_or(Value::Null); - self.record_pull_diagnostics(&query.method, query.params.as_ref(), &result); match self .validate_documents_after_query( &query.validated_documents, @@ -1038,6 +1037,12 @@ impl LspRuntime { .await { Ok(changed) => { + self.record_pull_diagnostics( + &query.method, + query.params.as_ref(), + &result, + &query.partial_chunks, + ); query.synchronization["postResponseChanged"] = Value::Array(changed); let mut output = json!({ "result": result, @@ -1962,20 +1967,34 @@ impl LspRuntime { Ok(()) } - fn record_pull_diagnostics(&mut self, method: &str, params: Option<&Value>, result: &Value) { - match method { - "textDocument/diagnostic" => { - if let Some(uri) = params - .and_then(|params| params.pointer("/textDocument/uri")) - .and_then(Value::as_str) - { - self.diagnostics.apply_pull_report(uri, result.clone()); - } - } - "workspace/diagnostic" => { - self.diagnostics.apply_workspace_pull_report(result.clone()); + fn record_pull_diagnostics( + &mut self, + method: &str, + params: Option<&Value>, + result: &Value, + partial_chunks: &[Value], + ) { + use crate::query::{QueryCommand, merge_partial_results, normalize_named_result}; + let command = match method { + "textDocument/diagnostic" => QueryCommand::DocumentDiagnostics, + "workspace/diagnostic" => QueryCommand::WorkspaceDiagnostics, + _ => return, + }; + // Cache validated effective content separately from exact raw Query evidence. + let Ok(report) = merge_partial_results(command, result.clone(), partial_chunks.to_vec()) + .and_then(|report| normalize_named_result(command, report)) + else { + return; + }; + if command == QueryCommand::DocumentDiagnostics { + if let Some(uri) = params + .and_then(|params| params.pointer("/textDocument/uri")) + .and_then(Value::as_str) + { + self.diagnostics.apply_document_pull_report(uri, report); } - _ => {} + } else { + self.diagnostics.apply_workspace_pull_report(report); } } @@ -3441,6 +3460,69 @@ mod tests { .expect("authenticated Query test watchdog"); } + #[test] + fn recursive_glob_file_operation_filters_reject_filename_suffixes() { + let filter = json!({ + "scheme": "file", + "pattern": {"glob": "**/main.rs", "matches": "file", "options": {"ignoreCase": false}} + }); + let mut initialized = json!({"capabilities": {"workspace": {"fileOperations": { + "didRename": {"filters": [filter.clone()]}, + "didCreate": {"filters": [filter]} + }}}}); + for (old, new, expected) in [ + ("domain.rs", "other.rs", false), + ("other.rs", "domain.rs", false), + ("main.rs", "other.rs", true), + ("other.rs", "nested/main.rs", true), + ] { + let operation = json!({ + "kind": "rename", "oldUri": format!("file:///workspace/{old}"), + "newUri": format!("file:///workspace/{new}"), "isDirectory": false + }); + assert_eq!( + file_operation_registered(&initialized, "didRename", &operation), + expected, + "{old} -> {new}" + ); + } + for (name, directory, expected) in [ + ("domain.rs", false, false), + ("main.rs", false, true), + ("main.rs", true, false), + ("MAIN.RS", false, false), + ] { + assert_eq!( + file_operation_registered( + &initialized, + "didCreate", + &json!({ + "kind": "create", "uri": format!("file:///workspace/{name}"), + "isDirectory": directory + }) + ), + expected, + "create {name}, directory={directory}" + ); + } + initialized["capabilities"]["workspace"]["fileOperations"]["didCreate"]["filters"][0]["pattern"] + ["options"]["ignoreCase"] = json!(true); + for (name, expected) in [("MAIN.RS", true), ("DOMAIN.RS", false)] { + assert_eq!( + file_operation_registered( + &initialized, + "didCreate", + &json!({ + "kind": "create", "uri": format!("file:///workspace/{name}"), + "isDirectory": false + }) + ), + expected, + "case-insensitive create {name}" + ); + } + } + #[test] fn file_operation_filters_honor_kind_glob_and_case_options() { let initialized = json!({ diff --git a/src/workspace/diagnostics.rs b/src/workspace/diagnostics.rs index 2f14c34..2ac5eeb 100644 --- a/src/workspace/diagnostics.rs +++ b/src/workspace/diagnostics.rs @@ -202,20 +202,22 @@ impl DiagnosticCache { } pub(crate) fn apply_pull_report(&mut self, uri: &str, report: Value) -> DiagnosticResult { - let kind = report.get("kind").and_then(Value::as_str); - if kind == Some("unchanged") { - self.clock = self.clock.saturating_add(1); - if let Some(snapshot) = self.snapshots.get_mut(&pull_key(uri)) { - snapshot.last_used = self.clock; - let result_id = report + let result = self.reconstruct_pull_report(uri, report); + self.store_pull_result(&result); + result + } + + // Reconstruction reads only pre-update snapshots: storing one larger result must not + // evict content needed to resolve another unchanged report in the same response. + fn reconstruct_pull_report(&self, uri: &str, report: Value) -> DiagnosticResult { + if report.get("kind").and_then(Value::as_str) == Some("unchanged") { + if let Some(snapshot) = self.snapshots.get(&pull_key(uri)) { + let mut result = render(snapshot, true, true); + result.result_id = report .get("resultId") .and_then(Value::as_str) .map(str::to_owned) - .or_else(|| snapshot.result_id.clone()); - snapshot.result_id.clone_from(&result_id); - let mut result = render(snapshot, true, true); - result.raw_report = report.clone(); - result.result_id = result_id; + .or(result.result_id); if let Some(result_id) = &result.result_id { result.effective_report["resultId"] = Value::String(result_id.clone()); } @@ -223,53 +225,59 @@ impl DiagnosticCache { if let Some(version) = report.get("version") { result.effective_report["version"] = version.clone(); } + result.raw_report = report; return result; } + } else if report.get("kind").and_then(Value::as_str) == Some("full") + && report.get("items").is_some_and(Value::is_array) + { return DiagnosticResult { uri: uri.to_owned(), - diagnostics: Value::Array(Vec::new()), + diagnostics: report["items"].clone(), + result_id: report + .get("resultId") + .and_then(Value::as_str) + .map(str::to_owned), + effective_report: report.clone(), raw_report: report, - effective_report: Value::Null, - result_id: None, version: None, - fresh: false, - complete: false, + fresh: true, + complete: true, closed: false, }; } - let diagnostics = report - .get("items") - .cloned() - .unwrap_or_else(|| Value::Array(Vec::new())); - let result_id = report - .get("resultId") - .and_then(Value::as_str) - .map(str::to_owned); + DiagnosticResult { + uri: uri.to_owned(), + diagnostics: Value::Array(Vec::new()), + raw_report: report, + effective_report: Value::Null, + result_id: None, + version: None, + fresh: false, + complete: false, + closed: false, + } + } + + fn store_pull_result(&mut self, result: &DiagnosticResult) { + if !result.complete { + return; + } self.store( - pull_key(uri), + pull_key(&result.uri), CachedDiagnostics { - uri: uri.to_owned(), - version: None, - diagnostics: diagnostics.clone(), - raw_report: report.clone(), - result_id: result_id.clone(), + uri: result.uri.clone(), + version: result.version, + diagnostics: result.diagnostics.clone(), + // Pull snapshots hold effective full content/current IDs, not current wire evidence. + raw_report: result.effective_report.clone(), + result_id: result.result_id.clone(), received_for_version: None, - closed: false, + closed: result.closed, serialized_bytes: 0, last_used: 0, }, ); - DiagnosticResult { - uri: uri.to_owned(), - diagnostics, - effective_report: report.clone(), - raw_report: report, - result_id, - version: None, - fresh: true, - complete: true, - closed: false, - } } /// Reconstructs unchanged main and related Document diagnostic reports. @@ -278,33 +286,38 @@ impl DiagnosticCache { uri: &str, report: Value, ) -> DiagnosticResult { - let raw_report = report.clone(); - let related_reports = report + let related = report .get("relatedDocuments") .and_then(Value::as_object) - .cloned(); - let mut result = self.apply_pull_report(uri, report); - let Some(related_reports) = related_reports else { + .map(|reports| { + reports + .iter() + .map(|(uri, report)| self.reconstruct_pull_report(uri, report.clone())) + .collect::>() + }); + let mut result = self.reconstruct_pull_report(uri, report); + if !result.complete + || related + .as_ref() + .is_some_and(|reports| reports.iter().any(|r| !r.complete)) + { + result.effective_report = Value::Null; + result.fresh = false; + result.complete = false; return result; - }; - let mut effective_related = serde_json::Map::new(); - for (related_uri, related_report) in related_reports { - let related = self.apply_pull_report(&related_uri, related_report.clone()); - result.fresh &= related.fresh; - result.complete &= related.complete; - effective_related.insert( - related_uri, - if related.effective_report.is_null() { - related_report - } else { - related.effective_report - }, - ); } - if !result.effective_report.is_null() { - result.effective_report["relatedDocuments"] = Value::Object(effective_related); + if let Some(related) = related { + result.effective_report["relatedDocuments"] = Value::Object( + related + .iter() + .map(|r| (r.uri.clone(), r.effective_report.clone())) + .collect(), + ); + for related in related { + self.store_pull_result(&related); + } } - result.raw_report = raw_report; + self.store_pull_result(&result); result } @@ -314,44 +327,47 @@ impl DiagnosticCache { &mut self, report: Value, ) -> WorkspaceDiagnosticResult { - let Some(items) = report.get("items").and_then(Value::as_array) else { + let reconstructed = report + .get("items") + .and_then(Value::as_array) + .and_then(|items| { + items + .iter() + .map(|item| { + let uri = item.get("uri")?.as_str()?; + let result = self.reconstruct_pull_report(uri, item.clone()); + result.complete.then_some(result) + }) + .collect::>>() + }); + let Some(reconstructed) = reconstructed else { return WorkspaceDiagnosticResult { effective_report: report.clone(), - raw_report: Value::Null, + raw_report: report, fresh: false, complete: false, workspace_complete: false, }; }; - let mut effective_items = Vec::with_capacity(items.len()); - let mut fresh = true; - let mut complete = true; - let mut had_unchanged = false; - for item in items { - let Some(uri) = item.get("uri").and_then(Value::as_str) else { - effective_items.push(item.clone()); - fresh = false; - complete = false; - continue; - }; - had_unchanged |= item.get("kind").and_then(Value::as_str) == Some("unchanged"); - let current = self.apply_pull_report(uri, item.clone()); - fresh &= current.fresh; - complete &= current.complete; - if current.effective_report.is_null() { - effective_items.push(item.clone()); - } else { - effective_items.push(current.effective_report); - } - } + let had_unchanged = reconstructed + .iter() + .any(|r| r.raw_report["kind"] == "unchanged"); let mut effective_report = report.clone(); - effective_report["items"] = Value::Array(effective_items); + effective_report["items"] = Value::Array( + reconstructed + .iter() + .map(|r| r.effective_report.clone()) + .collect(), + ); + for result in reconstructed { + self.store_pull_result(&result); + } WorkspaceDiagnosticResult { effective_report, raw_report: if had_unchanged { report } else { Value::Null }, - fresh, - complete, - workspace_complete: complete, + fresh: true, + complete: true, + workspace_complete: true, } } @@ -546,6 +562,131 @@ mod tests { ); } + #[test] + fn related_diagnostics_survive_cache_round_trip() { + let mut cache = DiagnosticCache::new(8, 8192); + cache.apply_document_pull_report( + "file:///main", + json!({ + "kind":"full", "resultId":"main-one", "items":[{"message":"main"}], + "relatedDocuments":{"file:///related":{ + "kind":"full", "resultId":"related-one", "items":[{"message":"related"}] + }} + }), + ); + let mut imported = DiagnosticCache::new(8, 8192); + imported.import_state(&cache.export_state()); + let raw = json!({"kind":"unchanged", "resultId":"main-two", + "relatedDocuments":{"file:///related":{"kind":"unchanged", "resultId":"related-two"}} + }); + let current = imported.apply_document_pull_report("file:///main", raw.clone()); + assert!(current.complete); + assert_eq!(current.raw_report, raw); + let mut third = DiagnosticCache::new(8, 8192); + third.import_state(&imported.export_state()); + for (uri, id, message) in [ + ("file:///main", "main-two", "main"), + ("file:///related", "related-two", "related"), + ] { + assert_eq!(third.pull_result_id(uri), Some(id)); + let snapshot = &third.snapshots[&pull_key(uri)]; + assert_eq!(snapshot.raw_report["kind"], "full"); + assert_eq!(snapshot.raw_report["resultId"], id); + assert_eq!(snapshot.raw_report["items"], json!([{"message":message}])); + } + assert_eq!( + third.snapshots[&pull_key("file:///main")].raw_report["relatedDocuments"]["file:///related"] + ["kind"], + "full" + ); + } + + #[test] + fn related_diagnostics_respect_cache_limits() { + let seed = json!({"kind":"full", "resultId":"main-one", "items":[], + "relatedDocuments":{"file:///related":{ + "kind":"full", "resultId":"related-one", "items":[{"message":"retained"}] + }} + }); + let mut cache = DiagnosticCache::new(8, 8192); + cache.apply_document_pull_report("file:///main", seed.clone()); + let before = cache.export_state(); + let rejected = cache.apply_document_pull_report( + "file:///main", + json!({ + "kind":"full", "resultId":"rejected", "items":[], + "relatedDocuments":{ + "file:///related":{"kind":"full", "resultId":"also-rejected", "items":[]}, + "file:///unknown":{"kind":"unchanged", "resultId":"missing"} + } + }), + ); + assert!(!rejected.complete); + assert!(rejected.effective_report.is_null()); + assert_eq!(cache.export_state(), before); + let rejected_workspace = cache.apply_workspace_pull_report(json!({"items":[ + {"uri":"file:///related", "version":null, "kind":"full", "resultId":"bad", "items":[]}, + {"uri":"file:///unknown", "version":null, "kind":"unchanged", "resultId":"missing"} + ]})); + assert!(!rejected_workspace.complete); + assert_eq!(cache.export_state(), before); + + // Both records fit initially; growing IDs forces eviction during storage. + cache.max_total_bytes = cache.total_bytes + 32; + let grown_id = "n".repeat(200); + let updated = cache.apply_document_pull_report( + "file:///main", + json!({ + "kind":"unchanged", "resultId":grown_id, + "relatedDocuments":{"file:///related":{"kind":"unchanged", "resultId":grown_id}} + }), + ); + assert!(updated.complete); + assert_eq!( + updated.effective_report["relatedDocuments"]["file:///related"]["items"], + json!([{"message":"retained"}]) + ); + assert!(cache.total_bytes <= cache.max_total_bytes); + assert!(cache.snapshots.len() < 2); + assert_eq!( + cache.total_bytes, + cache + .snapshots + .values() + .map(|s| s.serialized_bytes) + .sum::() + ); + + let mut bounded = DiagnosticCache::new(1, 8192); + assert!( + bounded + .apply_document_pull_report("file:///main", seed) + .complete + ); + assert_eq!(bounded.snapshots.len(), 1); + let state = bounded.export_state(); + assert_eq!(state[0]["uri"], "file:///main"); + assert_eq!( + state[0]["rawReport"]["relatedDocuments"]["file:///related"]["kind"], + "full" + ); + let mut imported = DiagnosticCache::new(8, 8192); + imported.import_state(&state); + assert_eq!(imported.snapshots.len(), 1); + assert!(imported.pull_result_id("file:///related").is_none()); + imported.invalidate_pull_result_ids(); + assert!(imported.pull_result_ids().is_empty()); + assert_eq!(imported.total_bytes, 0); + assert!( + !imported + .apply_pull_report( + "file:///main", + json!({"kind":"unchanged", "resultId":"lost"}) + ) + .complete + ); + } + #[test] fn oversized_snapshot_is_not_cached() { let mut cache = DiagnosticCache::new(1, 8); diff --git a/test_support/fake_lsp_server.rs b/test_support/fake_lsp_server.rs index 5a9e7e3..493b032 100644 --- a/test_support/fake_lsp_server.rs +++ b/test_support/fake_lsp_server.rs @@ -102,6 +102,12 @@ fn serve(scenario: Scenario, event_log: Option) -> ExitCode { let mut workspace_uri = None; let mut partial_limit_request = None; let mut open_documents = BTreeMap::new(); + let mut related_mode = env::args().find_map(|argument| { + argument + .strip_prefix("--related-diagnostics=") + .map(str::to_owned) + }); + let mut diagnostic_revision = 0; loop { let message = match read_frame(&mut input) { Ok(Some(message)) => message, @@ -175,7 +181,7 @@ fn serve(scenario: Scenario, event_log: Option) -> ExitCode { "codeActionProvider": {"resolveProvider": true}, "executeCommandProvider": {"commands": ["fixture.run"]}, "diagnosticProvider": { - "interFileDependencies": false, + "interFileDependencies": related_mode.is_some(), "workspaceDiagnostics": true } }) @@ -311,6 +317,28 @@ fn serve(scenario: Scenario, event_log: Option) -> ExitCode { return ExitCode::from(1); } } + Some("test/related-diagnostics-mode") if related_mode.is_some() => { + related_mode = Some(message["params"]["mode"].as_str().unwrap().to_owned()); + if result(&mut output, &message, Value::Null, scenario).is_err() { + return ExitCode::from(1); + } + } + Some("textDocument/diagnostic" | "workspace/diagnostic") + if related_mode.is_some() => + { + match related_diagnostics( + &mut output, + &message, + workspace_uri.as_ref().unwrap(), + related_mode.as_deref().unwrap(), + &mut diagnostic_revision, + scenario, + ) { + Ok(true) => partial_limit_request = Some(message["id"].clone()), + Ok(false) => {} + Err(()) => return ExitCode::from(1), + } + } Some("textDocument/diagnostic") => { if result( &mut output, @@ -817,6 +845,136 @@ fn notification_flood( } } +fn related_diagnostics( + output: &mut impl Write, + request: &Value, + workspace: &url::Url, + mode: &str, + revision: &mut usize, + scenario: Scenario, +) -> Result { + let workspace_report = request["method"] == "workspace/diagnostic"; + let main = workspace.join("main.rs").unwrap().to_string(); + let related = workspace.join("related.rs").unwrap().to_string(); + let previous = format!("main-{revision}"); + let expected = if workspace_report { + request["params"]["previousResultIds"] + .as_array() + .is_some_and(|ids| { + ids.iter() + .any(|entry| entry["uri"] == main && entry["value"] == previous) + && ids.iter().any(|entry| { + entry["uri"] == related && entry["value"] == format!("related-{revision}") + }) + }) + } else { + request["params"]["previousResultId"] == previous + }; + if *revision > 0 && !expected { + write_frame( + output, + &json!({ + "jsonrpc":"2.0", "id":request["id"], + "error":{"code":-32603, "message":"fixture received stale previous diagnostic IDs", "data": request["params"]} + }), + scenario, + )?; + return Ok(false); + } + let full = |name: &str, id: String| { + json!({ + "kind":"full", "resultId":id, + "items":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}}, "message":name}] + }) + }; + let next = *revision + 1; + let report = |name: &str| { + if *revision == 0 { + full(name, format!("{name}-{next}")) + } else { + json!({"kind":"unchanged", "resultId":format!("{name}-{next}")}) + } + }; + let mut main_report = report("main"); + let related_report = report("related"); + let progress = |value| { + json!({ + "jsonrpc":"2.0", "method":"$/progress", + "params":{"token":request["params"]["partialResultToken"], "value":value} + }) + }; + match mode { + "malformed" => { + result( + output, + request, + json!({"kind":"full","resultId":"poison","items":17}), + scenario, + )?; + } + "raw-malformed" => { + result(output, request, json!(17), scenario)?; + } + "unresolved" => { + let mut rejected = full("poison", "poison".to_owned()); + rejected["relatedDocuments"] = json!({ + (workspace.join("uncached.rs").unwrap().as_str()): {"kind":"unchanged","resultId":"poison"} + }); + result(output, request, rejected, scenario)?; + } + "error" | "cancel" => { + write_frame( + output, + &progress(json!({"relatedDocuments": { + (related): full("poison", "poison".to_owned()) + }})), + scenario, + )?; + if mode == "cancel" { + // The existing partial-byte limit initiates cancellation, then the main loop acknowledges it. + return Ok(true); + } + write_frame( + output, + &json!({ + "jsonrpc":"2.0", "id":request["id"], + "error":{"code":-32603,"message":"fixture diagnostic failure"} + }), + scenario, + )?; + } + _ => { + if workspace_report { + main_report["uri"] = json!(main); + main_report["version"] = Value::Null; + let mut related_report = related_report; + related_report["uri"] = json!(related); + related_report["version"] = Value::Null; + write_frame( + output, + &progress(json!({"items":[related_report]})), + scenario, + )?; + result(output, request, json!({"items":[main_report]}), scenario)?; + } else { + let related_reports = json!({(related): related_report}); + if mode == "partial" { + write_frame( + output, + &progress(json!({"relatedDocuments":related_reports})), + scenario, + )?; + } else { + main_report["relatedDocuments"] = related_reports; + } + result(output, request, main_report, scenario)?; + } + *revision = next; + } + } + Ok(false) +} + fn update_open_documents(message: &Value, open_documents: &mut BTreeMap) { let Some(uri) = message .pointer("/params/textDocument/uri") diff --git a/tests/owner_lifecycle.rs b/tests/owner_lifecycle.rs index b660f6b..168da5f 100644 --- a/tests/owner_lifecycle.rs +++ b/tests/owner_lifecycle.rs @@ -2036,6 +2036,276 @@ impl Drop for StopOwnerOnPanic<'_> { } } +fn related_diagnostic_documents(fixture: &Fixture) -> (String, String) { + for name in ["main.rs", "related.rs"] { + fs::write(fixture.workspace.join(name), "fn example() {}\n").unwrap(); + } + let uri = |name| { + url::Url::from_file_path(dunce::canonicalize(fixture.workspace.join(name)).unwrap()) + .unwrap() + .to_string() + }; + (uri("main.rs"), uri("related.rs")) +} + +fn related_diagnostic_query(fixture: &Fixture, workspace: bool) -> Value { + let path = fixture.workspace.join("main.rs"); + let mut arguments = vec![ + if workspace { + "workspace-diagnostics" + } else { + "document-diagnostics" + }, + "--workspace", + fixture.workspace.to_str().unwrap(), + "--server", + "fake", + "--request-timeout", + "5s", + ]; + if !workspace { + arguments.extend(["--file", path.to_str().unwrap()]); + } + fixture.command(&arguments) +} + +fn related_diagnostic_mode(fixture: &Fixture, mode: &str) { + fixture.command(&[ + "raw", + "--workspace", + fixture.workspace.to_str().unwrap(), + "--server", + "fake", + "--method", + "test/related-diagnostics-mode", + "--params-json", + &json!({"mode":mode}).to_string(), + "--request-timeout", + "5s", + ]); +} + +fn related_diagnostic_full(name: &str, revision: usize) -> Value { + json!({ + "kind":"full", "resultId":format!("{name}-{revision}"), + "items":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}}, "message":name}] + }) +} + +fn assert_related_diagnostic_response(response: &Value, related: &str, revision: usize) { + let mut expected = related_diagnostic_full("main", revision); + expected["relatedDocuments"] = json!({(related):related_diagnostic_full("related", revision)}); + if revision > 1 { + // Existing unchanged reconstruction includes each cached report's URI. + expected["uri"] = json!( + url::Url::parse(related) + .unwrap() + .join("main.rs") + .unwrap() + .as_str() + ); + expected["relatedDocuments"][related]["uri"] = json!(related); + } + assert_eq!(response["result"], expected); + assert!(response["context"]["ownerGeneration"].is_string()); + assert_eq!(response["diagnostics"]["complete"], true); + if revision > 1 { + assert_eq!( + response["diagnostics"]["rawReport"], + json!({ + "kind":"unchanged", "resultId":format!("main-{revision}"), + "relatedDocuments":{(related):{"kind":"unchanged", "resultId":format!("related-{revision}")}} + }) + ); + } +} + +#[test] +fn related_diagnostics_survive_cli_invocations() { + let fixture = Fixture::with_server_arguments(&["--related-diagnostics=final"]); + let _cleanup = StopOwnerOnPanic(&fixture, "fake"); + let (_, related) = related_diagnostic_documents(&fixture); + let first = related_diagnostic_query(&fixture, false); + assert_related_diagnostic_response(&first, &related, 1); + for revision in 2..=3 { + let response = related_diagnostic_query(&fixture, false); + assert_related_diagnostic_response(&response, &related, revision); + assert_eq!( + response["context"]["ownerGeneration"], + first["context"]["ownerGeneration"] + ); + } + fixture.stop(fixture.workspace.to_str().unwrap()); +} + +#[test] +fn related_diagnostics_from_partial_chunks_survive_cli_invocations() { + let fixture = Fixture::with_server_arguments(&["--related-diagnostics=partial"]); + let _cleanup = StopOwnerOnPanic(&fixture, "fake"); + let (_, related) = related_diagnostic_documents(&fixture); + let first = related_diagnostic_query(&fixture, false); + assert_related_diagnostic_response(&first, &related, 1); + for revision in 2..=3 { + let response = related_diagnostic_query(&fixture, false); + assert_related_diagnostic_response(&response, &related, revision); + assert_eq!( + response["context"]["ownerGeneration"], + first["context"]["ownerGeneration"] + ); + } + fixture.stop(fixture.workspace.to_str().unwrap()); +} + +#[test] +fn related_diagnostics_workspace_partials_persist() { + let fixture = Fixture::with_server_arguments(&["--related-diagnostics=workspace"]); + let _cleanup = StopOwnerOnPanic(&fixture, "fake"); + let (main, related) = related_diagnostic_documents(&fixture); + let mut generation = Value::Null; + for revision in 1..=3 { + let response = related_diagnostic_query(&fixture, true); + let items = response["result"]["items"].as_array().unwrap(); + assert_eq!(items.len(), 2); + for (name, uri) in [("main", &main), ("related", &related)] { + let mut expected = related_diagnostic_full(name, revision); + expected["uri"] = json!(uri); + expected["version"] = Value::Null; + assert!(items.contains(&expected), "{response}"); + } + assert_eq!(response["diagnostics"]["complete"], true); + if revision == 1 { + generation = response["context"]["ownerGeneration"].clone(); + } else { + assert_eq!(response["context"]["ownerGeneration"], generation); + assert!( + response["diagnostics"]["rawReport"]["items"] + .as_array() + .unwrap() + .iter() + .all(|item| item["kind"] == "unchanged") + ); + } + } + fixture.stop(fixture.workspace.to_str().unwrap()); +} + +#[test] +fn related_diagnostics_rejected_queries_preserve_cache() { + for (mode, code) in [ + ("malformed", "invalid_server_result"), + ("unresolved", "invalid_server_result"), + ("error", "server_error"), + ("cancel", "partial_result_too_large"), + ] { + let fixture = Fixture::with_configuration( + &["--related-diagnostics=final"], + if mode == "cancel" { + "[protocol]\nmax_partial_result_bytes = 64\n" + } else { + "" + }, + ); + let _cleanup = StopOwnerOnPanic(&fixture, "fake"); + let (_, related) = related_diagnostic_documents(&fixture); + let first = related_diagnostic_query(&fixture, false); + assert_related_diagnostic_response(&first, &related, 1); + related_diagnostic_mode(&fixture, mode); + let output = fixture.output(&[ + "document-diagnostics", + "--workspace", + fixture.workspace.to_str().unwrap(), + "--server", + "fake", + "--file", + fixture.workspace.join("main.rs").to_str().unwrap(), + "--trace-protocol", + "--request-timeout", + "5s", + ]); + assert!(!output.status.success(), "{mode}"); + assert!(output.stderr.is_empty()); + let failure: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(failure["error"]["code"], code, "{mode}: {failure}"); + if matches!(mode, "error" | "cancel") { + assert_eq!(failure["partialResult"]["complete"], false); + assert_eq!( + failure["partialResult"]["items"][0]["relatedDocuments"][&related]["items"][0]["message"], + "poison" + ); + } + if mode == "cancel" { + assert!( + failure["trace"]["frames"] + .as_array() + .unwrap() + .iter() + .any(|frame| frame["message"]["error"]["code"] == -32800) + ); + } + related_diagnostic_mode(&fixture, "final"); + let response = related_diagnostic_query(&fixture, false); + assert_related_diagnostic_response(&response, &related, 2); + assert_eq!( + response["context"]["ownerGeneration"], + first["context"]["ownerGeneration"] + ); + fixture.stop(fixture.workspace.to_str().unwrap()); + } +} + +#[test] +fn related_diagnostics_raw_queries_keep_exact_results() { + for mode in ["final", "partial"] { + let fixture = Fixture::with_server_arguments(&["--related-diagnostics=final"]); + let _cleanup = StopOwnerOnPanic(&fixture, "fake"); + let (main, related) = related_diagnostic_documents(&fixture); + let first = related_diagnostic_query(&fixture, false); + related_diagnostic_mode(&fixture, mode); + let raw = |revision| { + fixture.command(&[ + "raw", "--workspace", fixture.workspace.to_str().unwrap(), "--server", "fake", + "--method", "textDocument/diagnostic", "--params-json", + &json!({"textDocument":{"uri":main}, "previousResultId":format!("main-{revision}"), "partialResultToken":"raw-related-progress"}).to_string(), + "--trace-protocol", "--request-timeout", "5s", + ]) + }; + let response = raw(1); + let mut expected = json!({"kind":"unchanged", "resultId":"main-2"}); + if mode == "final" { + expected["relatedDocuments"] = + json!({(related.clone()):{"kind":"unchanged", "resultId":"related-2"}}); + } + assert_eq!(response["result"], expected); + assert!(response.get("diagnostics").is_none()); + let frames = response["trace"]["frames"].as_array().unwrap(); + assert!( + frames + .iter() + .any(|frame| frame["message"]["result"] == expected) + ); + if mode == "partial" { + assert!( + frames + .iter() + .any(|frame| frame["message"]["method"] == "$/progress" + && frame["message"]["params"]["token"] == "raw-related-progress" + && frame["message"]["params"]["value"]["relatedDocuments"][&related]["kind"] + == "unchanged") + ); + } + related_diagnostic_mode(&fixture, "raw-malformed"); + assert_eq!(raw(2)["result"], json!(17)); + related_diagnostic_mode(&fixture, "final"); + let response = related_diagnostic_query(&fixture, false); + assert_related_diagnostic_response(&response, &related, 3); + assert_eq!( + response["context"]["ownerGeneration"], + first["context"]["ownerGeneration"] + ); + fixture.stop(fixture.workspace.to_str().unwrap()); + } +} + #[test] fn owner_partial_results_chunks_merge_success() { let fixture = Fixture::new();