Skip to content

Commit 0f40608

Browse files
authored
fix(ci): add #[ignore] to ra_e2e_suite and install rust-analyzer in e2e job (#126)
* fix(ci): add #[ignore] to ra_e2e_suite and install rust-analyzer in e2e CI job Without #[ignore], ra_e2e_suite was not picked up by the CI e2e filter (--run-ignored ignored-only). Also adds rustup component add rust-analyzer step on Linux/macOS; Windows skips the ra_e2e suite via MCPLS_SKIP_RA=1. * fix(ci): run rust-analyzer e2e tests on Windows too * fix(e2e): resolve rust-analyzer via rustup fallback on Windows, extend code_action timeout - ra_probe: fall back to `rustup which rust-analyzer` when the binary is not in PATH — fixes CI on Windows where rustup toolchain dirs are excluded from PATH - sc_get_code_actions: increase retry loop from 15s to 30s and add 5s initial warm-up sleep; type-checking must complete before code action fixes appear - compute ca_end_col dynamically from the actual line length instead of a hardcoded column * fix(e2e): fix Windows file URI generation and code_action range - path_to_uri: use Url::from_file_path instead of manual string formatting; on Windows, canonicalize() returns \\?\ prefix which manual replace produced invalid file:////?\... URIs — causing all LSP requests to fail with -32603 - sc_get_code_actions: extend range to ca_line+1 because rust-analyzer reports the missing-semicolon diagnostic at the closing brace (one line below), so the range must overlap that position to trigger the quickfix - wait_until_ready: default timeout 120s on Windows (vs 60s on Linux/macOS) - ci: bump test-e2e timeout-minutes from 15 to 20 * fix(bridge): strip \\?\ prefix in path_to_uri on Windows without breaking tests Reverts to manual URI construction but strips the extended-path prefix (\\?\) that canonicalize() adds on Windows. Using Url::from_file_path panicked on unit tests that pass Unix-style paths (/test/file.rs) which are invalid Windows absolute paths. * test(e2e): add diagnostic logging to wait_until_ready for Windows debugging * fix(e2e): resolve get_code_actions returning empty actions Add codeActionLiteralSupport to LSP client capabilities so rust-analyzer returns CodeAction objects instead of falling back to legacy Command-only format. Without this capability declaration RA yields an empty response for structural refactoring assists such as "Implement missing members". Update sc_get_code_actions to use a point-cursor range on the impl line and target the empty `impl Greet for CodeActionTarget` block. Fix sc_get_cached_diagnostics to poll up to 15 s for the push-based publishDiagnostics cache, which may arrive asynchronously after pull-based diagnostics have already been satisfied by sc_get_diagnostics. * refactor(lsp): use CodeActionKind constants for value_set instead of raw strings * test(e2e): require 3 consecutive ready signals and extend Windows cached-diag timeout Readiness gate now requires 3 consecutive hover successes to avoid false-positive from transient RA analysis during workspace indexing (observed on Windows CI). sc_get_cached_diagnostics timeout raised to 60 s on Windows where publishDiagnostics notifications arrive later due to slower CI runners. * fix(bridge): use path_to_uri for cached diagnostics lookup on Windows Url::from_file_path on a canonicalized \?\-prefixed Windows path produces a URI that does not match what rust-analyzer stores in publishDiagnostics notifications (which omit the \?\ prefix). Replace the Url::from_file_path call in handle_cached_diagnostics with path_to_uri, which strips \?\ before building the URI, keeping the cache lookup key consistent with the stored key. * test(e2e): reduce cached-diagnostics timeout to 20 s on all platforms The URI mismatch fix (path_to_uri instead of Url::from_file_path) means the push cache lookup now works on Windows. The previous 60 s Windows timeout combined with the ~32 s readiness gate was hitting the 120 s nextest terminate-after limit. 20 s is sufficient: if the cache is populated (URI fix works) the data is found on the first poll; if not, we fail clearly within budget. * test(e2e): use lib.rs for cached-diagnostics push verification Newer rust-analyzer versions skip publishDiagnostics push for files that were already served via the pull-based textDocument/diagnostic API. sc_get_diagnostics calls pull on broken.rs, so RA no longer pushes for it. lib.rs is opened only via hover (wait_until_ready), never via pull diagnostics, so RA unconditionally pushes publishDiagnostics for it after initial workspace indexing. lib.rs also contains a guaranteed E0425 error (undefined_variable in has_error) that RA always reports. * fix(bridge): lowercase Windows drive letter in path_to_uri to match RA's URI normalization rust-analyzer normalizes Windows drive letters to lowercase in file URIs (e.g. C: → c:). path_to_uri was preserving the uppercase letter from canonicalize(), causing push-diagnostic cache lookups to miss every time on Windows CI — the stored key (RA's URI) and the lookup key (our URI) differed only in drive-letter case. * fix(bridge): normalize URI cache keys to lowercase on Windows Different tools (rust-analyzer, std::fs::canonicalize) produce Windows file URIs with inconsistent drive-letter casing (C: vs c:). Instead of assuming one canonical form, normalize both sides to lowercase at the cache boundary via uri_cache_key(). Applies to store, get, and clear operations in NotificationCache.
1 parent 6cae0e1 commit 0f40608

8 files changed

Lines changed: 194 additions & 85 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ jobs:
203203
needs: [detect-changes, fmt, clippy, build-tests, build]
204204
if: needs.detect-changes.outputs.run-full-ci == 'true'
205205
runs-on: ${{ matrix.os }}
206-
timeout-minutes: 15
206+
timeout-minutes: 20
207207
strategy:
208208
fail-fast: false
209209
matrix:
@@ -225,6 +225,8 @@ jobs:
225225
- name: Make binary executable
226226
if: matrix.os != 'windows-latest'
227227
run: chmod +x target/debug/mcpls
228+
- name: Install rust-analyzer
229+
run: rustup component add rust-analyzer
228230
- name: Run e2e tests
229231
run: cargo nextest run --archive-file nextest-archive.tar.zst --workspace-remap . --run-ignored ignored-only -E 'kind(test) and test(e2e)'
230232

crates/mcpls-core/src/bridge/notifications.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,21 @@ use serde::{Deserialize, Serialize};
1111
/// Maximum number of log entries to store.
1212
const MAX_LOG_ENTRIES: usize = 100;
1313

14+
/// Normalize a URI string to a stable cache key.
15+
///
16+
/// On Windows, URI comparisons must be case-insensitive: the filesystem is
17+
/// case-insensitive and different tools (e.g. rust-analyzer vs std) may
18+
/// produce drive letters in different cases (`C:` vs `c:`).
19+
/// Lowercasing the entire URI is safe for `file://` URIs because they have
20+
/// no case-sensitive query or fragment components.
21+
fn uri_cache_key(uri: &str) -> std::borrow::Cow<'_, str> {
22+
if cfg!(windows) {
23+
std::borrow::Cow::Owned(uri.to_ascii_lowercase())
24+
} else {
25+
std::borrow::Cow::Borrowed(uri)
26+
}
27+
}
28+
1429
/// Maximum number of server messages to store.
1530
const MAX_SERVER_MESSAGES: usize = 50;
1631

@@ -141,7 +156,8 @@ impl NotificationCache {
141156
version,
142157
diagnostics,
143158
};
144-
self.diagnostics.insert(uri.to_string(), info);
159+
self.diagnostics
160+
.insert(uri_cache_key(uri.as_str()).into_owned(), info);
145161
}
146162

147163
/// Store a log entry.
@@ -180,7 +196,7 @@ impl NotificationCache {
180196
#[inline]
181197
#[must_use]
182198
pub fn get_diagnostics(&self, uri: &str) -> Option<&DiagnosticInfo> {
183-
self.diagnostics.get(uri)
199+
self.diagnostics.get(uri_cache_key(uri).as_ref())
184200
}
185201

186202
/// Get all stored log entries.
@@ -201,7 +217,7 @@ impl NotificationCache {
201217
///
202218
/// Returns the cleared diagnostics if they existed.
203219
pub fn clear_diagnostics(&mut self, uri: &str) -> Option<DiagnosticInfo> {
204-
self.diagnostics.remove(uri)
220+
self.diagnostics.remove(uri_cache_key(uri).as_ref())
205221
}
206222

207223
/// Clear all diagnostics.

crates/mcpls-core/src/bridge/state.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,13 +212,15 @@ impl DocumentTracker {
212212
/// not occur for valid absolute paths.
213213
#[must_use]
214214
pub fn path_to_uri(path: &Path) -> Uri {
215-
// Convert path to file:// URI string and parse
216215
let uri_string = if cfg!(windows) {
217-
format!("file:///{}", path.display().to_string().replace('\\', "/"))
216+
let path_str = path.to_string_lossy();
217+
// canonicalize() on Windows adds a \\?\ extended-path prefix.
218+
// Strip it before building the URI — file:////?\C:/ is not valid.
219+
let stripped = path_str.strip_prefix(r"\\?\").unwrap_or(&path_str);
220+
format!("file:///{}", stripped.replace('\\', "/"))
218221
} else {
219222
format!("file://{}", path.display())
220223
};
221-
// Path-to-URI conversion should always succeed for valid paths
222224
#[allow(clippy::expect_used)]
223225
uri_string.parse().expect("failed to create URI from path")
224226
}

crates/mcpls-core/src/bridge/translator.rs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,8 @@ use lsp_types::{
1717
};
1818
use serde::{Deserialize, Serialize};
1919
use tokio::time::Duration;
20-
use url::Url;
2120

22-
use super::state::{ResourceLimits, detect_language};
21+
use super::state::{ResourceLimits, detect_language, path_to_uri};
2322
use super::{DocumentTracker, NotificationCache};
2423
use crate::bridge::encoding::mcp_to_lsp_position;
2524
use crate::error::{Error, Result};
@@ -1212,7 +1211,7 @@ impl Translator {
12121211
context: lsp_types::CodeActionContext {
12131212
diagnostics: context_diagnostics,
12141213
only,
1215-
trigger_kind: None,
1214+
trigger_kind: Some(lsp_types::CodeActionTriggerKind::INVOKED),
12161215
},
12171216
work_done_progress_params: WorkDoneProgressParams::default(),
12181217
partial_result_params: PartialResultParams::default(),
@@ -1222,7 +1221,6 @@ impl Translator {
12221221
let response: Option<lsp_types::CodeActionResponse> = client
12231222
.request("textDocument/codeAction", params, timeout_duration)
12241223
.await?;
1225-
12261224
let response_vec = response.unwrap_or_default();
12271225
let mut actions = Vec::with_capacity(response_vec.len());
12281226

@@ -1418,10 +1416,9 @@ impl Translator {
14181416
let path = PathBuf::from(file_path);
14191417
let validated_path = self.validate_path(&path)?;
14201418

1421-
// Convert path to URI format for cache lookup (cross-platform)
1422-
let uri = Url::from_file_path(&validated_path)
1423-
.map_err(|()| Error::InvalidUri(validated_path.display().to_string()))?
1424-
.to_string();
1419+
// Use path_to_uri (strips \\?\ on Windows) so the key matches what
1420+
// rust-analyzer stores in publishDiagnostics notifications.
1421+
let uri = path_to_uri(&validated_path).to_string();
14251422

14261423
let diagnostics =
14271424
self.notification_cache

crates/mcpls-core/src/lsp/lifecycle.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,9 @@ impl LspServer {
290290
Error::InvalidUri(format!("Invalid UTF-8 in path: {root_display}"))
291291
})?;
292292
let uri_str = if cfg!(windows) {
293-
format!("file:///{}", path_str.replace('\\', "/"))
293+
// Strip \\?\ extended-path prefix that canonicalize() adds on Windows.
294+
let stripped = path_str.strip_prefix(r"\\?\").unwrap_or(path_str);
295+
format!("file:///{}", stripped.replace('\\', "/"))
294296
} else {
295297
format!("file://{path_str}")
296298
};
@@ -343,6 +345,25 @@ impl LspServer {
343345
resolve_support: Some(lsp_types::CodeActionCapabilityResolveSupport {
344346
properties: vec!["edit".to_string()],
345347
}),
348+
// Declare supported action kinds so the server returns
349+
// CodeAction objects (not just legacy Command objects).
350+
code_action_literal_support: Some(lsp_types::CodeActionLiteralSupport {
351+
code_action_kind: lsp_types::CodeActionKindLiteralSupport {
352+
value_set: [
353+
lsp_types::CodeActionKind::EMPTY,
354+
lsp_types::CodeActionKind::QUICKFIX,
355+
lsp_types::CodeActionKind::REFACTOR,
356+
lsp_types::CodeActionKind::REFACTOR_EXTRACT,
357+
lsp_types::CodeActionKind::REFACTOR_INLINE,
358+
lsp_types::CodeActionKind::REFACTOR_REWRITE,
359+
lsp_types::CodeActionKind::SOURCE,
360+
lsp_types::CodeActionKind::SOURCE_ORGANIZE_IMPORTS,
361+
]
362+
.iter()
363+
.map(|k| k.as_str().to_string())
364+
.collect(),
365+
},
366+
}),
346367
..Default::default()
347368
}),
348369
..Default::default()

crates/mcpls-core/tests/common/ra_probe.rs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,30 @@ pub fn resolve_rust_analyzer() -> Resolution {
2929
return Resolution::Found(PathBuf::from(p));
3030
}
3131

32-
// Probe PATH by attempting to run rust-analyzer --version.
33-
match std::process::Command::new("rust-analyzer")
32+
// Probe PATH: if rust-analyzer --version succeeds, resolve the full path.
33+
if std::process::Command::new("rust-analyzer")
3434
.arg("--version")
3535
.output()
36+
.is_ok_and(|o| o.status.success())
37+
&& let Some(p) = find_in_path("rust-analyzer")
3638
{
37-
Ok(out) if out.status.success() => {
38-
// Resolve full path via `which`-equivalent: find the binary in PATH.
39-
find_in_path("rust-analyzer").map_or(Resolution::Missing, Resolution::Found)
40-
}
41-
_ => Resolution::Missing,
39+
return Resolution::Found(p);
40+
}
41+
42+
// Fallback: ask rustup where it installed the component.
43+
// This covers Windows CI where rustup toolchain bin dirs are not in PATH.
44+
if let Some(path) = std::process::Command::new("rustup")
45+
.args(["which", "rust-analyzer"])
46+
.output()
47+
.ok()
48+
.filter(|o| o.status.success())
49+
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
50+
.filter(|p| !p.is_empty())
51+
{
52+
return Resolution::Found(PathBuf::from(path));
4253
}
54+
55+
Resolution::Missing
4356
}
4457

4558
/// Find a binary in PATH, returning its absolute path.

crates/mcpls-core/tests/fixtures/rust_workspace/src/lib.rs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,26 @@ impl fmt::Display for Point {
7171
}
7272
}
7373

74-
/// Target function for code-action e2e testing.
75-
///
76-
/// The assignment `let ca_var = 1` is missing a semicolon so that
77-
/// rust-analyzer reliably offers an "add semicolon" quickfix here.
74+
/// Previously used for code-action quickfix testing (missing semicolon).
75+
/// The sub-case now uses structural actions instead; semicolon fixed.
7876
#[allow(dead_code)]
7977
pub fn code_action_target() {
80-
let ca_var = 1
78+
let _ca_var = 1;
79+
}
80+
81+
/// Trait used as a code-action trigger.
82+
///
83+
/// `CodeActionTarget` below has an empty `impl Greet` — rust-analyzer
84+
/// reliably offers "Implement missing members" there, a context-free
85+
/// structural action that does not depend on diagnostic data.
86+
pub trait Greet {
87+
fn hello(&self) -> String;
88+
}
89+
90+
/// Struct with an empty trait impl for code-action testing.
91+
#[allow(dead_code)]
92+
pub struct CodeActionTarget;
93+
94+
// split to multi-line so RA can offer "implement missing members" inside the block
95+
impl Greet for CodeActionTarget {
8196
}

0 commit comments

Comments
 (0)