#![warn(clippy::all)]
use url::Url;
#[must_use]
pub fn uri_key(uri: &str) -> String {
let trimmed = uri.trim();
if let Some(normalized) = normalize_legacy_windows_uri(trimmed) {
return normalized;
}
if let Ok(parsed) = Url::parse(trimmed) {
let mut value = parsed.as_str().to_string();
if parsed.scheme() == "file"
&& parsed.host_str() == Some("localhost")
&& let Some(path) = value.strip_prefix("file://localhost")
{
value = format!("file://{path}");
}
if let Some(rest) = value.strip_prefix("file:///")
&& rest.len() > 1
&& (rest.as_bytes()[1] == b':' || rest.as_bytes()[1] == b'|')
&& rest.as_bytes()[0].is_ascii_alphabetic()
{
let separator = if rest.as_bytes()[1] == b'|' { ":" } else { &rest[1..2] };
return format!(
"file:///{}{}{}",
rest[0..1].to_ascii_lowercase(),
separator,
&rest[2..]
);
}
value
} else {
trimmed.to_string()
}
}
fn normalize_legacy_windows_uri(uri: &str) -> Option<String> {
let trimmed = uri.trim();
if trimmed.is_empty() {
return None;
}
let path = if let Some(rest) = trimmed.strip_prefix("file://") {
if rest.starts_with('/') {
return None;
}
rest
} else {
trimmed
};
let path =
path.strip_prefix("localhost/").or_else(|| path.strip_prefix("LOCALHOST/")).unwrap_or(path);
normalize_windows_path_to_key(path).or_else(|| normalize_unc_path_to_key(path))
}
fn normalize_unc_path_to_key(path: &str) -> Option<String> {
let without_prefix = path.strip_prefix(r"\\").or_else(|| path.strip_prefix("//"))?;
let replaced = without_prefix.replace('\\', "/");
let mut parts = replaced.split('/').filter(|segment| !segment.is_empty());
let server = parts.next()?;
let share = parts.next()?;
let rest = parts.collect::<Vec<_>>().join("/");
if rest.is_empty() {
Some(format!("file://{server}/{share}"))
} else {
Some(format!("file://{server}/{share}/{rest}"))
}
}
fn normalize_windows_path_to_key(path: &str) -> Option<String> {
let path = path.trim_start_matches('/');
if path.len() < 3 {
return None;
}
let bytes = path.as_bytes();
if !bytes[0].is_ascii_alphabetic() || (bytes[1] != b':' && bytes[1] != b'|') {
return None;
}
let mut normalized = path.replace('\\', "/");
if normalized.as_bytes().get(1) == Some(&b'|') {
normalized.replace_range(1..2, ":");
}
if normalized.as_bytes().get(2) != Some(&b'/') {
normalized.insert(2, '/');
}
let drive = normalized[0..1].to_ascii_lowercase();
Some(format!("file:///{drive}{}", &normalized[1..]))
}
#[must_use]
pub fn is_file_uri(uri: &str) -> bool {
uri.get(..7).is_some_and(|prefix| prefix.eq_ignore_ascii_case("file://"))
}
#[must_use]
pub fn is_special_scheme(uri: &str) -> bool {
if let Ok(url) = Url::parse(uri) {
url.scheme() != "file"
} else {
uri.get(..9).is_some_and(|p| p.eq_ignore_ascii_case("untitled:"))
|| uri.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("git:"))
|| uri.get(..16).is_some_and(|p| p.eq_ignore_ascii_case("vscode-notebook:"))
|| uri.get(..21).is_some_and(|p| p.eq_ignore_ascii_case("vscode-notebook-cell:"))
|| uri.get(..11).is_some_and(|p| p.eq_ignore_ascii_case("vscode-vfs:"))
}
}
#[must_use]
pub fn uri_extension(uri: &str) -> Option<&str> {
let path_without_query_or_fragment =
uri.split_once(['?', '#']).map_or(uri, |(path_prefix, _)| path_prefix);
let path_part = path_without_query_or_fragment.rsplit(['/', '\\']).next()?;
let dot_pos = path_part.rfind('.')?;
if dot_pos == 0 {
return None;
}
let ext = &path_part[dot_pos + 1..];
if ext.is_empty() { None } else { Some(ext) }
}
#[cfg(test)]
mod tests {
use super::{is_file_uri, is_special_scheme, uri_extension, uri_key};
#[test]
fn normalizes_uri_keys() {
assert_eq!(uri_key("file:///tmp/test.pl"), "file:///tmp/test.pl");
assert_eq!(uri_key("file:///C:/Users/test.pl"), "file:///c:/Users/test.pl");
}
#[test]
fn normalizes_localhost_file_authority() {
assert_eq!(uri_key("file://localhost/tmp/test.pl"), uri_key("file:///tmp/test.pl"));
assert_eq!(
uri_key("file://localhost/C:/Users/test.pl"),
uri_key("file:///c:/Users/test.pl")
);
}
#[test]
fn preserves_non_local_file_authority() {
assert_eq!(uri_key("file://server/share/test.pl"), "file://server/share/test.pl");
}
#[test]
fn preserves_invalid_uri_values() {
assert_eq!(uri_key("not-a-uri"), "not-a-uri");
}
#[test]
fn normalizes_legacy_notepadpp_file_uri_two_slashes() {
assert_eq!(uri_key(r"file://C:\Users\dev\example.pl"), "file:///c:/Users/dev/example.pl");
assert_eq!(
uri_key(r"file://D:\projects\MyApp\script.pl"),
"file:///d:/projects/MyApp/script.pl"
);
}
#[test]
fn normalizes_bare_windows_path() {
assert_eq!(uri_key(r"C:\Users\dev\plain_path.pl"), "file:///c:/Users/dev/plain_path.pl");
assert_eq!(uri_key(r"c:\users\dev\lowercase.pl"), "file:///c:/users/dev/lowercase.pl");
}
#[test]
fn normalizes_legacy_file_uri_two_slashes_forward_slash() {
assert_eq!(uri_key("file://C:/Users/dev/example.pl"), "file:///c:/Users/dev/example.pl");
assert_eq!(
uri_key("file://D:/projects/MyApp/script.pl"),
"file:///d:/projects/MyApp/script.pl"
);
}
#[test]
fn normalizes_legacy_windows_drive_pipe_separator() {
assert_eq!(uri_key("file:///C|/Users/dev/example.pl"), "file:///c:/Users/dev/example.pl");
assert_eq!(
uri_key(r"file://D|\projects\MyApp\script.pl"),
"file:///d:/projects/MyApp/script.pl"
);
}
#[test]
fn normalizes_legacy_localhost_windows_uri_variants() {
assert_eq!(
uri_key(r"file://localhost/C:\Users\dev\example.pl"),
"file:///c:/Users/dev/example.pl"
);
assert_eq!(
uri_key("file://localhost/C:/Users/dev/example.pl"),
"file:///c:/Users/dev/example.pl"
);
assert_eq!(
uri_key(r"file://LOCALHOST/D:\projects\myapp\script.pl"),
"file:///d:/projects/myapp/script.pl"
);
}
#[test]
fn canonical_file_uri_three_slashes_unchanged_by_legacy_pass() {
assert_eq!(uri_key("file:///c:/Users/dev/example.pl"), "file:///c:/Users/dev/example.pl");
assert_eq!(uri_key("file:///C:/Users/dev/example.pl"), "file:///c:/Users/dev/example.pl");
}
#[test]
fn normalizes_legacy_unc_windows_path() {
assert_eq!(uri_key(r"\\server\share\folder\file.pl"), "file://server/share/folder/file.pl");
assert_eq!(
uri_key(r"file://\\server\share\folder\file.pl"),
"file://server/share/folder/file.pl"
);
}
#[test]
fn linux_paths_not_treated_as_windows() {
assert_eq!(uri_key("/home/user/file.pl"), "/home/user/file.pl");
assert_eq!(uri_key("file:///home/user/file.pl"), "file:///home/user/file.pl");
}
#[test]
fn detects_file_uris() {
assert!(is_file_uri("file:///tmp/test.pl"));
assert!(is_file_uri("file://localhost/tmp/test.pl"));
assert!(is_file_uri("FILE:///tmp/test.pl"));
assert!(!is_file_uri("file:test.pl"));
assert!(!is_file_uri("https://example.com"));
}
#[test]
fn detects_special_schemes() {
assert!(is_special_scheme("untitled:Untitled-1"));
assert!(is_special_scheme("git:/foo/bar"));
assert!(is_special_scheme("vscode-notebook-cell:/nb.ipynb#cell-id"));
assert!(!is_special_scheme("file:///tmp/test.pl"));
}
#[test]
fn detects_special_schemes_case_insensitive_fallback() {
assert!(is_special_scheme("UNTITLED:Untitled-1"));
assert!(is_special_scheme("GIT:relative/path"));
assert!(is_special_scheme("VSCODE-NOTEBOOK-CELL:bad uri"));
}
#[test]
fn extracts_extensions() {
assert_eq!(uri_extension("file:///tmp/test.pl"), Some("pl"));
assert_eq!(uri_extension("file:///tmp/file.pl?query=1"), Some("pl"));
assert_eq!(uri_extension("file:///tmp/file.pl#L10/permalink"), Some("pl"));
assert_eq!(uri_extension(r"C:\tmp\file.pl"), Some("pl"));
assert_eq!(uri_extension(r"C:\Users\.bashrc"), None);
assert_eq!(uri_extension(r"C:\Users\.gitignore"), None);
assert_eq!(uri_extension("file:///tmp/no-extension"), None);
}
}