From 1b73fa730f9cdc446eca12af58846840f0e516b4 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Thu, 22 Oct 2026 18:47:10 +0800 Subject: [PATCH] fix(provider): emit drive-letter file URIs for Windows paths path_to_file_uri encoded E:\dir and \\?\E:\dir verbatim, producing URIs with the path in the host. Clients could not map them back to files. Normalize separators, drop the verbatim prefix, and emit file:///E:/dir. filesystem_path now decodes through file_uri_path so the drive form round-trips on Windows. The URI helpers move to uri.rs to keep main.rs shrinking. --- crates/opy-provider/src/main.rs | 113 +-------------------------- crates/opy-provider/src/uri.rs | 134 ++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 111 deletions(-) create mode 100644 crates/opy-provider/src/uri.rs diff --git a/crates/opy-provider/src/main.rs b/crates/opy-provider/src/main.rs index 9f985d4a..48edf7d0 100644 --- a/crates/opy-provider/src/main.rs +++ b/crates/opy-provider/src/main.rs @@ -17,6 +17,8 @@ use workshop_rs::program::MappedText; mod edits; mod navigation; +mod uri; +use uri::{file_uri_path, filesystem_path, path_to_file_uri}; const PROTOCOL_VERSIONS: [&str; 7] = ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6"]; const PROJECT_LOADING_VERSION: &str = "1.1"; @@ -1774,97 +1776,6 @@ fn resolved_path(root: &Path, path: &str) -> PathBuf { } } -fn filesystem_path(value: &str) -> Option { - if let Some(path) = value.strip_prefix("file://") { - let path = if path.starts_with('/') { - path - } else { - return None; - }; - return Some(PathBuf::from(percent_decode(path)?)); - } - // A `scheme:` prefix makes `value` a URI, not a filesystem path — a - // document URI like `untitled:Untitled-1` must not silently resolve - // against the provider's working directory. A single letter before ':' - // is a Windows drive (`C:`), not a scheme. - if let Some(end) = value.find(':') { - let scheme = &value[..end]; - if scheme.len() > 1 - && scheme.starts_with(|c: char| c.is_ascii_alphabetic()) - && scheme - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) - { - return None; - } - } - Some(PathBuf::from(value)) -} - -fn file_uri_path(value: &str) -> Option { - let path = value.strip_prefix("file://")?; - if !path.starts_with('/') { - return None; - } - let path = percent_decode(path)?; - #[cfg(windows)] - let path = path - .strip_prefix('/') - .filter(|path| { - let bytes = path.as_bytes(); - bytes.first().is_some_and(u8::is_ascii_alphabetic) - && bytes.get(1) == Some(&b':') - && bytes.get(2) == Some(&b'/') - }) - .unwrap_or(&path); - Some(PathBuf::from(path)) -} - -fn percent_decode(value: &str) -> Option { - let bytes = value.as_bytes(); - let mut decoded = Vec::with_capacity(bytes.len()); - let mut index = 0; - while index < bytes.len() { - if bytes[index] == b'%' { - let high = bytes - .get(index + 1) - .and_then(|byte| (*byte as char).to_digit(16))?; - let low = bytes - .get(index + 2) - .and_then(|byte| (*byte as char).to_digit(16))?; - decoded.push((high * 16 + low) as u8); - index += 3; - } else { - decoded.push(bytes[index]); - index += 1; - } - } - String::from_utf8(decoded).ok() -} - -fn path_to_file_uri(path: &Path) -> String { - let raw = path_string(path); - let mut uri = String::from("file://"); - for byte in raw.bytes() { - if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'/' | b'~') { - uri.push(byte as char); - } else { - uri.push('%'); - uri.push( - char::from_digit(u32::from(byte >> 4), 16) - .expect("hex digit") - .to_ascii_uppercase(), - ); - uri.push( - char::from_digit(u32::from(byte & 0x0f), 16) - .expect("hex digit") - .to_ascii_uppercase(), - ); - } - } - uri -} - fn ok(id: Value, result: Value) -> Value { json!({ "jsonrpc": "2.0", "id": id, "result": result }) } @@ -1901,26 +1812,6 @@ mod tests { use super::*; use std::fs; - #[test] - fn file_uri_round_trips_spaces_and_unicode() { - let path = Path::new("/project/中文 file.opy"); - assert_eq!( - filesystem_path(&path_to_file_uri(path)), - Some(path.to_path_buf()) - ); - } - - #[test] - fn file_uri_path_handles_windows_drive_letter_uris() { - let path = file_uri_path("file:///D:/path/to/project.opy").expect("file URI path"); - let expected = if cfg!(windows) { - PathBuf::from("D:/path/to/project.opy") - } else { - PathBuf::from("/D:/path/to/project.opy") - }; - assert_eq!(path, expected); - } - #[test] fn initialize_advertises_only_implemented_capabilities() { let mut server = Server { diff --git a/crates/opy-provider/src/uri.rs b/crates/opy-provider/src/uri.rs new file mode 100644 index 00000000..133400c5 --- /dev/null +++ b/crates/opy-provider/src/uri.rs @@ -0,0 +1,134 @@ +use std::path::{Path, PathBuf}; + +pub(crate) fn filesystem_path(value: &str) -> Option { + if value.starts_with("file://") { + return file_uri_path(value); + } + // A `scheme:` prefix makes `value` a URI, not a filesystem path — a + // document URI like `untitled:Untitled-1` must not silently resolve + // against the provider's working directory. A single letter before ':' + // is a Windows drive (`C:`), not a scheme. + if let Some(end) = value.find(':') { + let scheme = &value[..end]; + if scheme.len() > 1 + && scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + { + return None; + } + } + Some(PathBuf::from(value)) +} + +pub(crate) fn file_uri_path(value: &str) -> Option { + let path = value.strip_prefix("file://")?; + if !path.starts_with('/') { + return None; + } + let path = percent_decode(path)?; + #[cfg(windows)] + let path = path + .strip_prefix('/') + .filter(|path| { + let bytes = path.as_bytes(); + bytes.first().is_some_and(u8::is_ascii_alphabetic) + && bytes.get(1) == Some(&b':') + && bytes.get(2) == Some(&b'/') + }) + .unwrap_or(&path); + Some(PathBuf::from(path)) +} + +fn percent_decode(value: &str) -> Option { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + let high = bytes + .get(index + 1) + .and_then(|byte| (*byte as char).to_digit(16))?; + let low = bytes + .get(index + 2) + .and_then(|byte| (*byte as char).to_digit(16))?; + decoded.push((high * 16 + low) as u8); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + String::from_utf8(decoded).ok() +} + +pub(crate) fn path_to_file_uri(path: &Path) -> String { + // Windows paths arrive as `E:\dir\f` or the verbatim `\\?\E:\dir\f`; the + // URI form is `file:///E:/dir/f`. + let raw = path.to_string_lossy().into_owned(); + let raw = raw.strip_prefix(r"\\?\").unwrap_or(&raw).replace('\\', "/"); + let drive = raw.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) + && raw.as_bytes().get(1) == Some(&b':'); + let mut uri = String::from(if drive { "file:///" } else { "file://" }); + if drive { + uri.push_str(&raw[..2]); + } + for byte in raw[if drive { 2 } else { 0 }..].bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'/' | b'~') { + uri.push(byte as char); + } else { + uri.push('%'); + uri.push( + char::from_digit(u32::from(byte >> 4), 16) + .expect("hex digit") + .to_ascii_uppercase(), + ); + uri.push( + char::from_digit(u32::from(byte & 0x0f), 16) + .expect("hex digit") + .to_ascii_uppercase(), + ); + } + } + uri +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_uri_round_trips_spaces_and_unicode() { + let path = Path::new("/project/中文 file.opy"); + assert_eq!( + filesystem_path(&path_to_file_uri(path)), + Some(path.to_path_buf()) + ); + } + + #[test] + fn file_uri_encodes_windows_paths_with_a_drive_and_no_host() { + for raw in [ + r"E:\Work\Bastion\src\main.opy", + r"\\?\E:\Work\Bastion\src\main.opy", + "E:/Work/Bastion/src/main.opy", + ] { + assert_eq!( + path_to_file_uri(Path::new(raw)), + "file:///E:/Work/Bastion/src/main.opy" + ); + } + } + + #[test] + fn file_uri_path_handles_windows_drive_letter_uris() { + let path = file_uri_path("file:///D:/path/to/project.opy").expect("file URI path"); + let expected = if cfg!(windows) { + PathBuf::from("D:/path/to/project.opy") + } else { + PathBuf::from("/D:/path/to/project.opy") + }; + assert_eq!(path, expected); + } +}