util: Implement host independent Url to PathBuf conversion (#47474)

Lukas Wirth created

We might interface with the LSP using URL heres across remotes that have
differing path styles which then breaks every now and then when we have
windows to unix connections. This should helps us fix these occurences
more correctly

Release Notes:

- N/A *or* Added/Fixed/Improved ...

Change summary

Cargo.lock                                            |   2 
Cargo.toml                                            |   1 
crates/acp_thread/src/acp_thread.rs                   |   6 
crates/agent_servers/src/acp.rs                       |   1 
crates/editor/src/clangd_ext.rs                       |  39 
crates/project/src/terminals.rs                       |  10 
crates/terminal/src/terminal.rs                       |  19 
crates/terminal/src/terminal_hyperlinks.rs            |  12 
crates/terminal_view/src/terminal_path_like_target.rs |   1 
crates/util/Cargo.toml                                |   2 
crates/util/src/paths.rs                              | 356 +++++++++++++
11 files changed, 422 insertions(+), 27 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -18241,6 +18241,7 @@ dependencies = [
  "log",
  "mach2 0.5.0",
  "nix 0.29.0",
+ "percent-encoding",
  "pretty_assertions",
  "rand 0.9.2",
  "regex",
@@ -18255,6 +18256,7 @@ dependencies = [
  "tempfile",
  "tendril",
  "unicase",
+ "url",
  "util_macros",
  "walkdir",
  "which 6.0.3",

Cargo.toml 🔗

@@ -596,6 +596,7 @@ partial-json-fixer = "0.5.3"
 parse_int = "0.9"
 pciid-parser = "0.8.0"
 pathdiff = "0.2"
+percent-encoding = "2.3.2"
 pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "d5b5bb0c4558a51d8cc76b514bc870fd1c042f16" }
 pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "d5b5bb0c4558a51d8cc76b514bc870fd1c042f16" }
 pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "d5b5bb0c4558a51d8cc76b514bc870fd1c042f16" }

crates/acp_thread/src/acp_thread.rs 🔗

@@ -2631,6 +2631,7 @@ mod tests {
                 None,
                 0,
                 cx.background_executor(),
+                PathStyle::local(),
             )
             .unwrap();
             builder.subscribe(cx)
@@ -2705,6 +2706,7 @@ mod tests {
                 None,
                 0,
                 cx.background_executor(),
+                PathStyle::local(),
             )
             .unwrap();
             builder.subscribe(cx)
@@ -2791,6 +2793,7 @@ mod tests {
                     Some(completion_tx),
                     cx,
                     vec![],
+                    PathStyle::local(),
                 )
             })
             .await
@@ -4080,6 +4083,7 @@ mod tests {
                 None,
                 0,
                 cx.background_executor(),
+                PathStyle::local(),
             )
             .unwrap();
             builder.subscribe(cx)
@@ -4126,6 +4130,7 @@ mod tests {
                 None,
                 0,
                 cx.background_executor(),
+                PathStyle::local(),
             )
             .unwrap();
             builder.subscribe(cx)
@@ -4186,6 +4191,7 @@ mod tests {
                 None,
                 0,
                 cx.background_executor(),
+                PathStyle::local(),
             )
             .unwrap();
             builder.subscribe(cx)

crates/agent_servers/src/acp.rs 🔗

@@ -1284,6 +1284,7 @@ impl acp::Client for ClientDelegate {
                                 None,
                                 0,
                                 cx.background_executor(),
+                                thread.project().read(cx).path_style(cx),
                             )?;
                             let lower = cx.new(|cx| builder.subscribe(cx));
                             thread.on_terminal_provider_event(

crates/editor/src/clangd_ext.rs 🔗

@@ -1,11 +1,10 @@
-use std::path::PathBuf;
-
 use anyhow::Context as _;
 use gpui::{App, Context, Entity, Window};
 use language::Language;
 use project::lsp_store::lsp_ext_command::SwitchSourceHeaderResult;
 use rpc::proto;
-use util::paths::PathStyle;
+use url::Url;
+use util::paths::{PathStyle, UrlExt as _};
 use workspace::{OpenOptions, OpenVisible};
 
 use crate::lsp_ext::find_specific_language_server_in_selection;
@@ -77,25 +76,22 @@ pub fn switch_source_header(
         if switch_source_header.0.is_empty() {
             return Ok(());
         }
-
-        let goto = switch_source_header
-            .0
-            .strip_prefix("file://")
-            .with_context(|| {
-                format!(
-                    "Parsing file url \"{}\" returned from switch source/header failed",
-                    switch_source_header.0
-                )
-            })?;
+        let path_style = workspace.update(cx, |ws, cx| ws.path_style(cx));
+        let path = Url::parse(&switch_source_header.0).with_context(|| {
+            format!(
+                "Parsing URL \"{}\" returned from switch source/header failed",
+                switch_source_header.0
+            )
+        })?;
+        let path = path.to_file_path_ext(path_style).map_err(|()| {
+            anyhow::anyhow!(
+                "URL conversion to file path failed for \"{}\"",
+                switch_source_header.0
+            )
+        })?;
 
         workspace
             .update_in(cx, |workspace, window, cx| {
-                let goto = if workspace.path_style(cx).is_windows() {
-                    goto.strip_prefix('/').unwrap_or(goto)
-                } else {
-                    goto
-                };
-                let path = PathBuf::from(goto);
                 workspace.open_abs_path(
                     path,
                     OpenOptions {
@@ -107,7 +103,10 @@ pub fn switch_source_header(
                 )
             })
             .with_context(|| {
-                format!("Switch source/header could not open \"{goto}\" in workspace")
+                format!(
+                    "Switch source/header could not open \"{}\" in workspace",
+                    switch_source_header.0
+                )
             })?
             .await
             .map(|_| ())

crates/project/src/terminals.rs 🔗

@@ -91,8 +91,8 @@ impl Project {
                 .unwrap_or_else(get_default_system_shell),
             None => settings.shell.program(),
         };
-        let is_windows = self.path_style(cx).is_windows();
-        let shell_kind = ShellKind::new(&shell, is_windows);
+        let path_style = self.path_style(cx);
+        let shell_kind = ShellKind::new(&shell, path_style.is_windows());
 
         // Prepare a task for resolving the environment
         let env_task =
@@ -248,6 +248,7 @@ impl Project {
                         Some(completion_tx),
                         cx,
                         activation_script,
+                        path_style,
                     ))
                 })??
                 .await?;
@@ -356,7 +357,7 @@ impl Project {
             None => settings.shell.program(),
         };
 
-        let is_windows = self.path_style(cx).is_windows();
+        let path_style = self.path_style(cx);
 
         // Prepare a task for resolving the environment
         let env_task =
@@ -364,7 +365,7 @@ impl Project {
 
         let lang_registry = self.languages.clone();
         cx.spawn(async move |project, cx| {
-            let shell_kind = ShellKind::new(&shell, is_windows);
+            let shell_kind = ShellKind::new(&shell, path_style.is_windows());
             let mut env = env_task.await.unwrap_or_default();
             env.extend(settings.env);
 
@@ -412,6 +413,7 @@ impl Project {
                         None,
                         cx,
                         activation_script,
+                        path_style,
                     ))
                 })??
                 .await?;

crates/terminal/src/terminal.rs 🔗

@@ -50,7 +50,7 @@ use terminal_hyperlinks::RegexSearches;
 use terminal_settings::{AlternateScroll, CursorShape, TerminalSettings};
 use theme::{ActiveTheme, Theme};
 use urlencoding;
-use util::truncate_and_trailoff;
+use util::{paths::PathStyle, truncate_and_trailoff};
 
 use std::{
     borrow::Cow,
@@ -347,6 +347,7 @@ impl TerminalBuilder {
         max_scroll_history_lines: Option<usize>,
         window_id: u64,
         background_executor: &BackgroundExecutor,
+        path_style: PathStyle,
     ) -> Result<TerminalBuilder> {
         // Create a display-only terminal (no actual PTY).
         let default_cursor_style = AlacCursorStyle::from(cursor_shape);
@@ -411,6 +412,7 @@ impl TerminalBuilder {
             child_exited: None,
             event_loop_task: Task::ready(Ok(())),
             background_executor: background_executor.clone(),
+            path_style,
         };
 
         Ok(TerminalBuilder {
@@ -434,6 +436,7 @@ impl TerminalBuilder {
         completion_tx: Option<Sender<Option<ExitStatus>>>,
         cx: &App,
         activation_script: Vec<String>,
+        path_style: PathStyle,
     ) -> Task<Result<TerminalBuilder>> {
         let version = release_channel::AppVersion::global(cx);
         let background_executor = cx.background_executor().clone();
@@ -640,6 +643,7 @@ impl TerminalBuilder {
                 child_exited: None,
                 event_loop_task: Task::ready(Ok(())),
                 background_executor,
+                path_style,
             };
 
             if !activation_script.is_empty() && no_task {
@@ -863,6 +867,7 @@ pub struct Terminal {
     child_exited: Option<ExitStatus>,
     event_loop_task: Task<Result<(), anyhow::Error>>,
     background_executor: BackgroundExecutor,
+    path_style: PathStyle,
 }
 
 struct CopyTemplate {
@@ -1181,6 +1186,7 @@ impl Terminal {
                     term,
                     point,
                     &mut self.hyperlink_regex_searches,
+                    self.path_style,
                 ) {
                     Some(hyperlink) => {
                         self.process_hyperlink(hyperlink, *open, cx);
@@ -1869,6 +1875,7 @@ impl Terminal {
                 &term_lock,
                 point,
                 &mut self.hyperlink_regex_searches,
+                self.path_style,
             );
             drop(term_lock);
 
@@ -1960,6 +1967,7 @@ impl Terminal {
                         &term_lock,
                         point,
                         &mut self.hyperlink_regex_searches,
+                        self.path_style,
                     )
                 } {
                     if mouse_down_hyperlink == mouse_up_hyperlink {
@@ -2283,6 +2291,7 @@ impl Terminal {
             None,
             cx,
             self.activation_script.clone(),
+            self.path_style,
         )
     }
 }
@@ -2553,6 +2562,7 @@ mod tests {
                     Some(completion_tx),
                     cx,
                     vec![],
+                    PathStyle::local(),
                 )
             })
             .await
@@ -2574,6 +2584,7 @@ mod tests {
                 None,
                 0,
                 cx.background_executor(),
+                PathStyle::local(),
             )
             .unwrap()
             .subscribe(cx)
@@ -2697,6 +2708,7 @@ mod tests {
                     Some(completion_tx),
                     cx,
                     Vec::new(),
+                    PathStyle::local(),
                 )
             })
             .await
@@ -2772,6 +2784,7 @@ mod tests {
                     Some(completion_tx),
                     cx,
                     Vec::new(),
+                    PathStyle::local(),
                 )
             })
             .await
@@ -2958,6 +2971,7 @@ mod tests {
                 None,
                 0,
                 cx.background_executor(),
+                PathStyle::local(),
             )
             .unwrap()
             .subscribe(cx)
@@ -3005,6 +3019,7 @@ mod tests {
                 None,
                 0,
                 cx.background_executor(),
+                PathStyle::local(),
             )
             .unwrap()
             .subscribe(cx)
@@ -3046,6 +3061,7 @@ mod tests {
                 None,
                 0,
                 cx.background_executor(),
+                PathStyle::local(),
             )
             .unwrap()
             .subscribe(cx)
@@ -3256,6 +3272,7 @@ mod tests {
                         None,
                         cx,
                         vec![],
+                        PathStyle::local(),
                     )
                 })
                 .await

crates/terminal/src/terminal_hyperlinks.rs 🔗

@@ -16,6 +16,7 @@ use std::{
     time::{Duration, Instant},
 };
 use url::Url;
+use util::paths::{PathStyle, UrlExt};
 
 const URL_REGEX: &str = r#"(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file://|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>"\s{-}\^⟨⟩`']+"#;
 const WIDE_CHAR_SPACERS: Flags =
@@ -70,6 +71,7 @@ pub(super) fn find_from_grid_point<T: EventListener>(
     term: &Term<T>,
     point: AlacPoint,
     regex_searches: &mut RegexSearches,
+    path_style: PathStyle,
 ) -> Option<(String, bool, Match)> {
     let grid = term.grid();
     let link = grid.index(point).hyperlink();
@@ -135,7 +137,7 @@ pub(super) fn find_from_grid_point<T: EventListener>(
             // (e.g., file:///C:/path -> C:\path)
             if maybe_url_or_path.starts_with("file://") {
                 if let Ok(url) = Url::parse(&maybe_url_or_path) {
-                    if let Ok(path) = url.to_file_path() {
+                    if let Ok(path) = url.to_file_path_ext(path_style) {
                         return (path.to_string_lossy().into_owned(), false, word_match);
                     }
                 }
@@ -1217,7 +1219,12 @@ mod tests {
                 }
 
                 TEST_REGEX_SEARCHES.with(|regex_searches| {
-                    find_from_grid_point(&term, point, &mut regex_searches.borrow_mut())
+                    find_from_grid_point(
+                        &term,
+                        point,
+                        &mut regex_searches.borrow_mut(),
+                        PathStyle::local(),
+                    )
                 })
             }
         }
@@ -1819,6 +1826,7 @@ mod tests {
                 &term,
                 expected_hyperlink.hovered_grid_point,
                 &mut regex_searches.borrow_mut(),
+                PathStyle::local(),
             )
         });
         let check_hyperlink_match =

crates/util/Cargo.toml 🔗

@@ -42,6 +42,8 @@ smol.workspace = true
 take-until.workspace = true
 tempfile.workspace = true
 unicase.workspace = true
+url.workspace = true
+percent-encoding.workspace = true
 util_macros = { workspace = true, optional = true }
 walkdir.workspace = true
 which.workspace = true

crates/util/src/paths.rs 🔗

@@ -1407,6 +1407,153 @@ impl WslPath {
     }
 }
 
+pub trait UrlExt {
+    /// A version of `url::Url::to_file_path` that does platform handling based on the provided `PathStyle` instead of the host platform.
+    ///
+    /// Prefer using this over `url::Url::to_file_path` when you need to handle paths in a cross-platform way as is the case for remoting interactions.
+    fn to_file_path_ext(&self, path_style: PathStyle) -> Result<PathBuf, ()>;
+}
+
+impl UrlExt for url::Url {
+    // Copied from `url::Url::to_file_path`, but the `cfg` handling is replaced with runtime branching on `PathStyle`
+    fn to_file_path_ext(&self, source_path_style: PathStyle) -> Result<PathBuf, ()> {
+        if let Some(segments) = self.path_segments() {
+            let host = match self.host() {
+                None | Some(url::Host::Domain("localhost")) => None,
+                Some(_) if source_path_style.is_windows() && self.scheme() == "file" => {
+                    self.host_str()
+                }
+                _ => return Err(()),
+            };
+
+            let str_len = self.as_str().len();
+            let estimated_capacity = if source_path_style.is_windows() {
+                // remove scheme: - has possible \\ for hostname
+                str_len.saturating_sub(self.scheme().len() + 1)
+            } else {
+                // remove scheme://
+                str_len.saturating_sub(self.scheme().len() + 3)
+            };
+            return match source_path_style {
+                PathStyle::Posix => {
+                    file_url_segments_to_pathbuf_posix(estimated_capacity, host, segments)
+                }
+                PathStyle::Windows => {
+                    file_url_segments_to_pathbuf_windows(estimated_capacity, host, segments)
+                }
+            };
+        }
+
+        fn file_url_segments_to_pathbuf_posix(
+            estimated_capacity: usize,
+            host: Option<&str>,
+            segments: std::str::Split<'_, char>,
+        ) -> Result<PathBuf, ()> {
+            use percent_encoding::percent_decode;
+
+            if host.is_some() {
+                return Err(());
+            }
+
+            let mut bytes = Vec::new();
+            bytes.try_reserve(estimated_capacity).map_err(|_| ())?;
+
+            for segment in segments {
+                bytes.push(b'/');
+                bytes.extend(percent_decode(segment.as_bytes()));
+            }
+
+            // A windows drive letter must end with a slash.
+            if bytes.len() > 2
+                && bytes[bytes.len() - 2].is_ascii_alphabetic()
+                && matches!(bytes[bytes.len() - 1], b':' | b'|')
+            {
+                bytes.push(b'/');
+            }
+
+            let path = String::from_utf8(bytes).map_err(|_| ())?;
+            debug_assert!(
+                PathStyle::Posix.is_absolute(&path),
+                "to_file_path() failed to produce an absolute Path"
+            );
+
+            Ok(PathBuf::from(path))
+        }
+
+        fn file_url_segments_to_pathbuf_windows(
+            estimated_capacity: usize,
+            host: Option<&str>,
+            mut segments: std::str::Split<'_, char>,
+        ) -> Result<PathBuf, ()> {
+            use percent_encoding::percent_decode_str;
+            let mut string = String::new();
+            string.try_reserve(estimated_capacity).map_err(|_| ())?;
+            if let Some(host) = host {
+                string.push_str(r"\\");
+                string.push_str(host);
+            } else {
+                let first = segments.next().ok_or(())?;
+
+                match first.len() {
+                    2 => {
+                        if !first.starts_with(|c| char::is_ascii_alphabetic(&c))
+                            || first.as_bytes()[1] != b':'
+                        {
+                            return Err(());
+                        }
+
+                        string.push_str(first);
+                    }
+
+                    4 => {
+                        if !first.starts_with(|c| char::is_ascii_alphabetic(&c)) {
+                            return Err(());
+                        }
+                        let bytes = first.as_bytes();
+                        if bytes[1] != b'%'
+                            || bytes[2] != b'3'
+                            || (bytes[3] != b'a' && bytes[3] != b'A')
+                        {
+                            return Err(());
+                        }
+
+                        string.push_str(&first[0..1]);
+                        string.push(':');
+                    }
+
+                    _ => return Err(()),
+                }
+            };
+
+            for segment in segments {
+                string.push('\\');
+
+                // Currently non-unicode windows paths cannot be represented
+                match percent_decode_str(segment).decode_utf8() {
+                    Ok(s) => string.push_str(&s),
+                    Err(..) => return Err(()),
+                }
+            }
+            // ensure our estimated capacity was good
+            if cfg!(test) {
+                debug_assert!(
+                    string.len() <= estimated_capacity,
+                    "len: {}, capacity: {}",
+                    string.len(),
+                    estimated_capacity
+                );
+            }
+            debug_assert!(
+                PathStyle::Windows.is_absolute(&string),
+                "to_file_path() failed to produce an absolute Path"
+            );
+            let path = PathBuf::from(string);
+            Ok(path)
+        }
+        Err(())
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use crate::rel_path::rel_path;
@@ -2702,4 +2849,213 @@ mod tests {
         let path = r"\\windows.localhost\Distro\foo";
         assert_eq!(WslPath::from_path(&path), None);
     }
+
+    #[test]
+    fn test_url_to_file_path_ext_posix_basic() {
+        use super::UrlExt;
+
+        let url = url::Url::parse("file:///home/user/file.txt").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Posix),
+            Ok(PathBuf::from("/home/user/file.txt"))
+        );
+
+        let url = url::Url::parse("file:///").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Posix),
+            Ok(PathBuf::from("/"))
+        );
+
+        let url = url::Url::parse("file:///a/b/c/d/e").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Posix),
+            Ok(PathBuf::from("/a/b/c/d/e"))
+        );
+    }
+
+    #[test]
+    fn test_url_to_file_path_ext_posix_percent_encoding() {
+        use super::UrlExt;
+
+        let url = url::Url::parse("file:///home/user/file%20with%20spaces.txt").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Posix),
+            Ok(PathBuf::from("/home/user/file with spaces.txt"))
+        );
+
+        let url = url::Url::parse("file:///path%2Fwith%2Fencoded%2Fslashes").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Posix),
+            Ok(PathBuf::from("/path/with/encoded/slashes"))
+        );
+
+        let url = url::Url::parse("file:///special%23chars%3F.txt").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Posix),
+            Ok(PathBuf::from("/special#chars?.txt"))
+        );
+    }
+
+    #[test]
+    fn test_url_to_file_path_ext_posix_localhost() {
+        use super::UrlExt;
+
+        let url = url::Url::parse("file://localhost/home/user/file.txt").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Posix),
+            Ok(PathBuf::from("/home/user/file.txt"))
+        );
+    }
+
+    #[test]
+    fn test_url_to_file_path_ext_posix_rejects_host() {
+        use super::UrlExt;
+
+        let url = url::Url::parse("file://somehost/home/user/file.txt").unwrap();
+        assert_eq!(url.to_file_path_ext(PathStyle::Posix), Err(()));
+    }
+
+    #[test]
+    fn test_url_to_file_path_ext_posix_windows_drive_letter() {
+        use super::UrlExt;
+
+        let url = url::Url::parse("file:///C:").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Posix),
+            Ok(PathBuf::from("/C:/"))
+        );
+
+        let url = url::Url::parse("file:///D|").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Posix),
+            Ok(PathBuf::from("/D|/"))
+        );
+    }
+
+    #[test]
+    fn test_url_to_file_path_ext_windows_basic() {
+        use super::UrlExt;
+
+        let url = url::Url::parse("file:///C:/Users/user/file.txt").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Windows),
+            Ok(PathBuf::from("C:\\Users\\user\\file.txt"))
+        );
+
+        let url = url::Url::parse("file:///D:/folder/subfolder/file.rs").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Windows),
+            Ok(PathBuf::from("D:\\folder\\subfolder\\file.rs"))
+        );
+
+        let url = url::Url::parse("file:///C:/").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Windows),
+            Ok(PathBuf::from("C:\\"))
+        );
+    }
+
+    #[test]
+    fn test_url_to_file_path_ext_windows_encoded_drive_letter() {
+        use super::UrlExt;
+
+        let url = url::Url::parse("file:///C%3A/Users/file.txt").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Windows),
+            Ok(PathBuf::from("C:\\Users\\file.txt"))
+        );
+
+        let url = url::Url::parse("file:///c%3a/Users/file.txt").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Windows),
+            Ok(PathBuf::from("c:\\Users\\file.txt"))
+        );
+
+        let url = url::Url::parse("file:///D%3A/folder/file.txt").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Windows),
+            Ok(PathBuf::from("D:\\folder\\file.txt"))
+        );
+
+        let url = url::Url::parse("file:///d%3A/folder/file.txt").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Windows),
+            Ok(PathBuf::from("d:\\folder\\file.txt"))
+        );
+    }
+
+    #[test]
+    fn test_url_to_file_path_ext_windows_unc_path() {
+        use super::UrlExt;
+
+        let url = url::Url::parse("file://server/share/path/file.txt").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Windows),
+            Ok(PathBuf::from("\\\\server\\share\\path\\file.txt"))
+        );
+
+        let url = url::Url::parse("file://server/share").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Windows),
+            Ok(PathBuf::from("\\\\server\\share"))
+        );
+    }
+
+    #[test]
+    fn test_url_to_file_path_ext_windows_percent_encoding() {
+        use super::UrlExt;
+
+        let url = url::Url::parse("file:///C:/Users/user/file%20with%20spaces.txt").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Windows),
+            Ok(PathBuf::from("C:\\Users\\user\\file with spaces.txt"))
+        );
+
+        let url = url::Url::parse("file:///C:/special%23chars%3F.txt").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Windows),
+            Ok(PathBuf::from("C:\\special#chars?.txt"))
+        );
+    }
+
+    #[test]
+    fn test_url_to_file_path_ext_windows_invalid_drive() {
+        use super::UrlExt;
+
+        let url = url::Url::parse("file:///1:/path/file.txt").unwrap();
+        assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(()));
+
+        let url = url::Url::parse("file:///CC:/path/file.txt").unwrap();
+        assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(()));
+
+        let url = url::Url::parse("file:///C/path/file.txt").unwrap();
+        assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(()));
+
+        let url = url::Url::parse("file:///invalid").unwrap();
+        assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(()));
+    }
+
+    #[test]
+    fn test_url_to_file_path_ext_non_file_scheme() {
+        use super::UrlExt;
+
+        let url = url::Url::parse("http://example.com/path").unwrap();
+        assert_eq!(url.to_file_path_ext(PathStyle::Posix), Err(()));
+        assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(()));
+
+        let url = url::Url::parse("https://example.com/path").unwrap();
+        assert_eq!(url.to_file_path_ext(PathStyle::Posix), Err(()));
+        assert_eq!(url.to_file_path_ext(PathStyle::Windows), Err(()));
+    }
+
+    #[test]
+    fn test_url_to_file_path_ext_windows_localhost() {
+        use super::UrlExt;
+
+        let url = url::Url::parse("file://localhost/C:/Users/file.txt").unwrap();
+        assert_eq!(
+            url.to_file_path_ext(PathStyle::Windows),
+            Ok(PathBuf::from("C:\\Users\\file.txt"))
+        );
+    }
 }