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
3 changes: 2 additions & 1 deletion crates/ark/src/fixtures/dummy_frontend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion crates/ark/src/interface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Regex> = Lazy::new(|| Regex::new(r"Browse\[\d+\]").unwrap());
Expand Down Expand Up @@ -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
(
Expand Down
1 change: 1 addition & 0 deletions crates/ark/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions crates/ark/src/lsp/state_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(())
Expand Down
4 changes: 2 additions & 2 deletions crates/ark/src/modules/positron/frontend-methods.R
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion crates/ark/src/modules/positron/srcref.R
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ srcref_info <- function(srcref) {
lines <- srcfile$lines

if (!identical(file, "") && !identical(file, "<text>")) {
if (!is_ark_uri(file)) {
if (!is_uri(file)) {
# TODO: Handle absolute paths by using `wd`
file <- normalizePath(file, mustWork = FALSE)
}
Expand Down
5 changes: 5 additions & 0 deletions crates/ark/src/modules/positron/view.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
10 changes: 8 additions & 2 deletions crates/ark/src/sys/windows/traps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
195 changes: 195 additions & 0 deletions crates/ark/src/url.rs
Original file line number Diff line number Diff line change
@@ -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<Url, url::ParseError> {
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<std::path::Path>) -> Result<Url, ()> {
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");
}
}