Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ jobs:
needs: [detect-changes, fmt, clippy, build-tests, build]
if: needs.detect-changes.outputs.run-full-ci == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 15
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
Expand All @@ -225,6 +225,8 @@ jobs:
- name: Make binary executable
if: matrix.os != 'windows-latest'
run: chmod +x target/debug/mcpls
- name: Install rust-analyzer
run: rustup component add rust-analyzer
- name: Run e2e tests
run: cargo nextest run --archive-file nextest-archive.tar.zst --workspace-remap . --run-ignored ignored-only -E 'kind(test) and test(e2e)'

Expand Down
22 changes: 19 additions & 3 deletions crates/mcpls-core/src/bridge/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@ use serde::{Deserialize, Serialize};
/// Maximum number of log entries to store.
const MAX_LOG_ENTRIES: usize = 100;

/// Normalize a URI string to a stable cache key.
///
/// On Windows, URI comparisons must be case-insensitive: the filesystem is
/// case-insensitive and different tools (e.g. rust-analyzer vs std) may
/// produce drive letters in different cases (`C:` vs `c:`).
/// Lowercasing the entire URI is safe for `file://` URIs because they have
/// no case-sensitive query or fragment components.
fn uri_cache_key(uri: &str) -> std::borrow::Cow<'_, str> {
if cfg!(windows) {
std::borrow::Cow::Owned(uri.to_ascii_lowercase())
} else {
std::borrow::Cow::Borrowed(uri)
}
}

/// Maximum number of server messages to store.
const MAX_SERVER_MESSAGES: usize = 50;

Expand Down Expand Up @@ -141,7 +156,8 @@ impl NotificationCache {
version,
diagnostics,
};
self.diagnostics.insert(uri.to_string(), info);
self.diagnostics
.insert(uri_cache_key(uri.as_str()).into_owned(), info);
}

/// Store a log entry.
Expand Down Expand Up @@ -180,7 +196,7 @@ impl NotificationCache {
#[inline]
#[must_use]
pub fn get_diagnostics(&self, uri: &str) -> Option<&DiagnosticInfo> {
self.diagnostics.get(uri)
self.diagnostics.get(uri_cache_key(uri).as_ref())
}

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

/// Clear all diagnostics.
Expand Down
8 changes: 5 additions & 3 deletions crates/mcpls-core/src/bridge/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,13 +212,15 @@ impl DocumentTracker {
/// not occur for valid absolute paths.
#[must_use]
pub fn path_to_uri(path: &Path) -> Uri {
// Convert path to file:// URI string and parse
let uri_string = if cfg!(windows) {
format!("file:///{}", path.display().to_string().replace('\\', "/"))
let path_str = path.to_string_lossy();
// canonicalize() on Windows adds a \\?\ extended-path prefix.
// Strip it before building the URI — file:////?\C:/ is not valid.
let stripped = path_str.strip_prefix(r"\\?\").unwrap_or(&path_str);
format!("file:///{}", stripped.replace('\\', "/"))
} else {
format!("file://{}", path.display())
};
// Path-to-URI conversion should always succeed for valid paths
#[allow(clippy::expect_used)]
uri_string.parse().expect("failed to create URI from path")
}
Expand Down
13 changes: 5 additions & 8 deletions crates/mcpls-core/src/bridge/translator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@ use lsp_types::{
};
use serde::{Deserialize, Serialize};
use tokio::time::Duration;
use url::Url;

use super::state::{ResourceLimits, detect_language};
use super::state::{ResourceLimits, detect_language, path_to_uri};
use super::{DocumentTracker, NotificationCache};
use crate::bridge::encoding::mcp_to_lsp_position;
use crate::error::{Error, Result};
Expand Down Expand Up @@ -1212,7 +1211,7 @@ impl Translator {
context: lsp_types::CodeActionContext {
diagnostics: context_diagnostics,
only,
trigger_kind: None,
trigger_kind: Some(lsp_types::CodeActionTriggerKind::INVOKED),
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
Expand All @@ -1222,7 +1221,6 @@ impl Translator {
let response: Option<lsp_types::CodeActionResponse> = client
.request("textDocument/codeAction", params, timeout_duration)
.await?;

let response_vec = response.unwrap_or_default();
let mut actions = Vec::with_capacity(response_vec.len());

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

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

let diagnostics =
self.notification_cache
Expand Down
23 changes: 22 additions & 1 deletion crates/mcpls-core/src/lsp/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,9 @@ impl LspServer {
Error::InvalidUri(format!("Invalid UTF-8 in path: {root_display}"))
})?;
let uri_str = if cfg!(windows) {
format!("file:///{}", path_str.replace('\\', "/"))
// Strip \\?\ extended-path prefix that canonicalize() adds on Windows.
let stripped = path_str.strip_prefix(r"\\?\").unwrap_or(path_str);
format!("file:///{}", stripped.replace('\\', "/"))
} else {
format!("file://{path_str}")
};
Expand Down Expand Up @@ -343,6 +345,25 @@ impl LspServer {
resolve_support: Some(lsp_types::CodeActionCapabilityResolveSupport {
properties: vec!["edit".to_string()],
}),
// Declare supported action kinds so the server returns
// CodeAction objects (not just legacy Command objects).
code_action_literal_support: Some(lsp_types::CodeActionLiteralSupport {
code_action_kind: lsp_types::CodeActionKindLiteralSupport {
value_set: [
lsp_types::CodeActionKind::EMPTY,
lsp_types::CodeActionKind::QUICKFIX,
lsp_types::CodeActionKind::REFACTOR,
lsp_types::CodeActionKind::REFACTOR_EXTRACT,
lsp_types::CodeActionKind::REFACTOR_INLINE,
lsp_types::CodeActionKind::REFACTOR_REWRITE,
lsp_types::CodeActionKind::SOURCE,
lsp_types::CodeActionKind::SOURCE_ORGANIZE_IMPORTS,
]
.iter()
.map(|k| k.as_str().to_string())
.collect(),
},
}),
..Default::default()
}),
..Default::default()
Expand Down
27 changes: 20 additions & 7 deletions crates/mcpls-core/tests/common/ra_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,30 @@ pub fn resolve_rust_analyzer() -> Resolution {
return Resolution::Found(PathBuf::from(p));
}

// Probe PATH by attempting to run rust-analyzer --version.
match std::process::Command::new("rust-analyzer")
// Probe PATH: if rust-analyzer --version succeeds, resolve the full path.
if std::process::Command::new("rust-analyzer")
.arg("--version")
.output()
.is_ok_and(|o| o.status.success())
&& let Some(p) = find_in_path("rust-analyzer")
{
Ok(out) if out.status.success() => {
// Resolve full path via `which`-equivalent: find the binary in PATH.
find_in_path("rust-analyzer").map_or(Resolution::Missing, Resolution::Found)
}
_ => Resolution::Missing,
return Resolution::Found(p);
}

// Fallback: ask rustup where it installed the component.
// This covers Windows CI where rustup toolchain bin dirs are not in PATH.
if let Some(path) = std::process::Command::new("rustup")
.args(["which", "rust-analyzer"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|p| !p.is_empty())
{
return Resolution::Found(PathBuf::from(path));
}

Resolution::Missing
}

/// Find a binary in PATH, returning its absolute path.
Expand Down
25 changes: 20 additions & 5 deletions crates/mcpls-core/tests/fixtures/rust_workspace/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,26 @@ impl fmt::Display for Point {
}
}

/// Target function for code-action e2e testing.
///
/// The assignment `let ca_var = 1` is missing a semicolon so that
/// rust-analyzer reliably offers an "add semicolon" quickfix here.
/// Previously used for code-action quickfix testing (missing semicolon).
/// The sub-case now uses structural actions instead; semicolon fixed.
#[allow(dead_code)]
pub fn code_action_target() {
let ca_var = 1
let _ca_var = 1;
}

/// Trait used as a code-action trigger.
///
/// `CodeActionTarget` below has an empty `impl Greet` — rust-analyzer
/// reliably offers "Implement missing members" there, a context-free
/// structural action that does not depend on diagnostic data.
pub trait Greet {
fn hello(&self) -> String;
}

/// Struct with an empty trait impl for code-action testing.
#[allow(dead_code)]
pub struct CodeActionTarget;

// split to multi-line so RA can offer "implement missing members" inside the block
impl Greet for CodeActionTarget {
}
Loading
Loading