diff --git a/crates/ark/src/fixtures/dummy_frontend.rs b/crates/ark/src/fixtures/dummy_frontend.rs index 3a12f8fc5..b5a98fae2 100644 --- a/crates/ark/src/fixtures/dummy_frontend.rs +++ b/crates/ark/src/fixtures/dummy_frontend.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use std::sync::Mutex; use std::sync::MutexGuard; use std::sync::OnceLock; -use std::time::Duration; use amalthea::fixtures::dummy_frontend::DummyConnection; use amalthea::fixtures::dummy_frontend::DummyFrontend; @@ -68,6 +67,8 @@ impl DummyArkFrontend { #[cfg(unix)] #[track_caller] pub fn wait_for_cleanup() { + use std::time::Duration; + use crate::sys::interface::CLEANUP_SIGNAL; let (lock, cvar) = &CLEANUP_SIGNAL; diff --git a/crates/ark/src/interface.rs b/crates/ark/src/interface.rs index 5fe843faa..3f5b4bb85 100644 --- a/crates/ark/src/interface.rs +++ b/crates/ark/src/interface.rs @@ -136,6 +136,7 @@ use crate::startup; use crate::sys::console::console_to_utf8; use crate::ui::UiCommMessage; use crate::ui::UiCommSender; +use crate::url::ExtUrl; pub static CAPTURE_CONSOLE_OUTPUT: AtomicBool = AtomicBool::new(false); static RE_DEBUG_PROMPT: Lazy = Lazy::new(|| Regex::new(r"Browse\[\d+\]").unwrap()); @@ -968,7 +969,12 @@ impl RMain { } } - let loc = req.code_location().log_err().flatten(); + let loc = req.code_location().log_err().flatten().map(|mut loc| { + // Normalize URI for Windows compatibility. Positron sends URIs like + // `file:///c%3A/...` which do not match DAP's breakpoint path keys. + loc.uri = ExtUrl::normalize(loc.uri); + loc + }); // Return the code to the R console to be evaluated and the corresponding exec count ( diff --git a/crates/ark/src/lib.rs b/crates/ark/src/lib.rs index 65f9d5419..57b3f3b82 100644 --- a/crates/ark/src/lib.rs +++ b/crates/ark/src/lib.rs @@ -44,6 +44,7 @@ pub mod thread; pub mod traps; pub mod treesitter; pub mod ui; +pub mod url; pub mod variables; pub mod version; pub mod view; diff --git a/crates/ark/src/lsp/state_handlers.rs b/crates/ark/src/lsp/state_handlers.rs index 3aef51729..6a5bbfc39 100644 --- a/crates/ark/src/lsp/state_handlers.rs +++ b/crates/ark/src/lsp/state_handlers.rs @@ -57,6 +57,7 @@ use crate::lsp::main_loop::DidOpenVirtualDocumentParams; use crate::lsp::main_loop::LspState; use crate::lsp::state::workspace_uris; use crate::lsp::state::WorldState; +use crate::url::ExtUrl; // Handlers that mutate the world state @@ -254,10 +255,13 @@ pub(crate) fn did_change( lsp::main_loop::index_update(vec![uri.clone()], state.clone()); - // Notify console about document change to invalidate breakpoints + // Notify console about document change to invalidate breakpoints. + // Normalize URI to avoid Windows issues with `file:///c%3A` paths. lsp_state .console_notification_tx - .send(ConsoleNotification::DidChangeDocument(uri.clone())) + .send(ConsoleNotification::DidChangeDocument(ExtUrl::normalize( + uri.clone(), + ))) .log_err(); Ok(()) diff --git a/crates/ark/src/modules/positron/frontend-methods.R b/crates/ark/src/modules/positron/frontend-methods.R index 45a9d62cc..1dcad063d 100644 --- a/crates/ark/src/modules/positron/frontend-methods.R +++ b/crates/ark/src/modules/positron/frontend-methods.R @@ -38,8 +38,8 @@ line = 0L, column = 0L ) { - # Don't normalize if that's an `ark:` URI - if (is_ark_uri(file)) { + # Don't normalize if that's already a URI + if (is_uri(file)) { kind <- "uri" } else { kind <- "path" diff --git a/crates/ark/src/modules/positron/srcref.R b/crates/ark/src/modules/positron/srcref.R index 6ee67c89b..6e0bf42b9 100644 --- a/crates/ark/src/modules/positron/srcref.R +++ b/crates/ark/src/modules/positron/srcref.R @@ -118,7 +118,7 @@ srcref_info <- function(srcref) { lines <- srcfile$lines if (!identical(file, "") && !identical(file, "")) { - if (!is_ark_uri(file)) { + if (!is_uri(file)) { # TODO: Handle absolute paths by using `wd` file <- normalizePath(file, mustWork = FALSE) } diff --git a/crates/ark/src/modules/positron/view.R b/crates/ark/src/modules/positron/view.R index 7402bfcb1..ba656f68d 100644 --- a/crates/ark/src/modules/positron/view.R +++ b/crates/ark/src/modules/positron/view.R @@ -195,6 +195,11 @@ is_ark_uri <- function(path) { startsWith(path, "ark:") } +is_uri <- function(path) { + # Require scheme to be at least 2 chars to exclude Windows drive letters like "C:" + grepl("^[a-zA-Z][a-zA-Z0-9+.-]+:", path) +} + ark_ns_uri <- function(path) { .ps.Call("ps_ark_ns_uri", path) } diff --git a/crates/ark/src/sys/windows/traps.rs b/crates/ark/src/sys/windows/traps.rs index 4b7769d98..dc158d50c 100644 --- a/crates/ark/src/sys/windows/traps.rs +++ b/crates/ark/src/sys/windows/traps.rs @@ -9,7 +9,13 @@ use crate::traps::backtrace_handler; pub fn register_trap_handlers() { unsafe { - libc::signal(libc::SIGSEGV, backtrace_handler as libc::sighandler_t); - libc::signal(libc::SIGILL, backtrace_handler as libc::sighandler_t); + libc::signal( + libc::SIGSEGV, + backtrace_handler as *const () as libc::sighandler_t, + ); + libc::signal( + libc::SIGILL, + backtrace_handler as *const () as libc::sighandler_t, + ); } } diff --git a/crates/ark/src/url.rs b/crates/ark/src/url.rs new file mode 100644 index 000000000..8cc1c4144 --- /dev/null +++ b/crates/ark/src/url.rs @@ -0,0 +1,195 @@ +// +// url.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// + +use url::Url; + +/// Extended URL utilities for ark. +/// +/// On Windows, file URIs can have different representations of the same file. +/// Positron sends `file:///c%3A/...` (URL-encoded colon, lowercase drive) in +/// execute requests and LSP notifications. These variants can be problematic +/// when URI paths are used as HashMap keys. +/// +/// This module provides normalized URI construction and parsing to ensure +/// consistent identity across subsystems (DAP breakpoints, LSP documents, +/// R code locations). +/// +/// Use `ExtUrl` methods instead of `Url` methods when working with file URIs +/// that will be used as keys or need to match across different sources. +pub struct ExtUrl; + +impl ExtUrl { + /// Parse a URL string and normalize file URIs for consistent comparison. + pub fn parse(s: &str) -> Result { + let url = Url::parse(s)?; + Ok(Self::normalize(url)) + } + + /// Convert a file path to a normalized file URI. + pub fn from_file_path(path: impl AsRef) -> Result { + let url = Url::from_file_path(path)?; + Ok(Self::normalize(url)) + } + + /// Normalize a file URI for consistent comparison. + /// + /// On Windows, Positron sends URIs like `file:///c%3A/...` (URL-encoded + /// colon, lowercase drive letter). By round-tripping through the filesystem + /// path representation, we normalize encoding variants. We then uppercase + /// the drive letter. + #[cfg(windows)] + pub fn normalize(uri: Url) -> Url { + if uri.scheme() != "file" { + return uri; + } + + // Round-trip through filesystem path to get canonical form. + // This decodes URL-encoded characters like %3A -> : + let Some(uri) = uri + .to_file_path() + .ok() + .and_then(|path| Url::from_file_path(&path).ok()) + else { + log::warn!("Failed to normalize file URI: {uri}"); + return uri; + }; + uppercase_windows_drive_in_uri(uri) + } + + /// No-op on non-Windows platforms. + #[cfg(not(windows))] + pub fn normalize(uri: Url) -> Url { + uri + } +} + +/// Uppercase the drive letter in a Windows file URI for consistent hashing. +#[cfg(windows)] +fn uppercase_windows_drive_in_uri(mut uri: Url) -> Url { + let path = uri.path(); + let mut chars = path.chars(); + + // Match pattern: "/" + drive letter + ":" + let drive = match (chars.next(), chars.next(), chars.next()) { + (Some('/'), Some(drive), Some(':')) if drive.is_ascii_alphabetic() => drive, + _ => return uri, + }; + + let upper = drive.to_ascii_uppercase(); + + if drive != upper { + let new_path = format!("/{upper}:{}", &path[3..]); + uri.set_path(&new_path); + } + + uri +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_non_file_unchanged() { + let uri = Url::parse("ark://namespace/test.R").unwrap(); + let normalized = ExtUrl::normalize(uri.clone()); + assert_eq!(normalized, uri); + } + + #[test] + fn test_ext_url_parse_non_file() { + let uri = ExtUrl::parse("ark://namespace/test.R").unwrap(); + assert_eq!(uri.as_str(), "ark://namespace/test.R"); + } + + #[test] + #[cfg(not(windows))] + fn test_normalize_is_noop_on_non_windows() { + // On non-Windows, normalize just returns the input unchanged + let uri = Url::parse("file:///home/user/test.R").unwrap(); + let normalized = ExtUrl::normalize(uri.clone()); + assert_eq!(normalized, uri); + } + + #[test] + #[cfg(not(windows))] + fn test_ext_url_from_file_path_unix() { + let uri = ExtUrl::from_file_path("/home/user/test.R").unwrap(); + assert_eq!(uri.as_str(), "file:///home/user/test.R"); + } + + // Windows-specific tests + + #[test] + #[cfg(windows)] + fn test_normalize_decodes_percent_encoded_colon() { + // Positron sends URIs with encoded colon + let uri = Url::parse("file:///c%3A/Users/test/file.R").unwrap(); + let normalized = ExtUrl::normalize(uri); + assert_eq!(normalized.as_str(), "file:///C:/Users/test/file.R"); + } + + #[test] + #[cfg(windows)] + fn test_normalize_decodes_percent_encoded_colon_lowercase_hex() { + // %3a (lowercase hex) variant + let uri = Url::parse("file:///c%3a/Users/test/file.R").unwrap(); + let normalized = ExtUrl::normalize(uri); + assert_eq!(normalized.as_str(), "file:///C:/Users/test/file.R"); + } + + #[test] + #[cfg(windows)] + fn test_normalize_uppercases_drive_letter() { + let uri = Url::parse("file:///c:/Users/test/file.R").unwrap(); + let normalized = ExtUrl::normalize(uri); + assert_eq!(normalized.as_str(), "file:///C:/Users/test/file.R"); + } + + #[test] + #[cfg(windows)] + fn test_normalize_preserves_uppercase_drive() { + let uri = Url::parse("file:///C:/Users/test/file.R").unwrap(); + let normalized = ExtUrl::normalize(uri.clone()); + assert_eq!(normalized, uri); + } + + #[test] + #[cfg(windows)] + fn test_normalize_preserves_spaces_encoding() { + // Spaces should remain percent-encoded after round-trip + let uri = Url::parse("file:///C:/Users/test%20user/my%20file.R").unwrap(); + let normalized = ExtUrl::normalize(uri); + assert_eq!( + normalized.as_str(), + "file:///C:/Users/test%20user/my%20file.R" + ); + } + + #[test] + #[cfg(windows)] + fn test_normalize_decodes_colon_preserves_spaces() { + // Both encoded colon and spaces + let uri = Url::parse("file:///c%3A/Users/test%20user/file.R").unwrap(); + let normalized = ExtUrl::normalize(uri); + assert_eq!(normalized.as_str(), "file:///C:/Users/test%20user/file.R"); + } + + #[test] + #[cfg(windows)] + fn test_ext_url_parse() { + let uri = ExtUrl::parse("file:///c%3A/Users/test/file.R").unwrap(); + assert_eq!(uri.as_str(), "file:///C:/Users/test/file.R"); + } + + #[test] + #[cfg(windows)] + fn test_ext_url_from_file_path() { + let uri = ExtUrl::from_file_path("C:\\Users\\test\\file.R").unwrap(); + assert_eq!(uri.as_str(), "file:///C:/Users/test/file.R"); + } +}