use std::path::{Path, PathBuf};
use std::str::FromStr;
use lsp_types::*;
use percent_encoding::percent_decode_str;
use url::Url;
use liwe::model::Key;
pub struct BasePath {
url: Url,
}
impl BasePath {
pub fn new(base_path: String) -> Self {
let url = Url::parse(&base_path).expect("valid base URL");
Self {
url: canonical(url),
}
}
pub fn from_path(path: &str) -> Self {
let url = Url::from_directory_path(path).expect("valid base path");
Self {
url: canonical(url),
}
}
pub fn key_to_url(&self, key: &Key) -> Uri {
self.build_url(&key.relative_path)
}
pub fn relative_to_full_path(&self, path: &str) -> Uri {
self.build_url(path)
}
pub fn name_to_url(&self, name: &str) -> Uri {
self.build_url(name)
}
pub fn url_to_key(&self, uri: &Uri) -> Key {
let url = canonical(Url::parse(&uri.to_string()).expect("valid URI"));
let base = self.url.to_file_path().expect("base is a file URL");
let target = url.to_file_path().expect("URI is a file URL");
let relative = target.strip_prefix(&base).unwrap_or(&target);
let joined = relative
.components()
.filter_map(|c| match c {
std::path::Component::Normal(os) => Some(os.to_string_lossy().to_string()),
_ => None,
})
.collect::<Vec<_>>()
.join("/");
Key::name(&joined)
}
pub fn resolve_relative_url(&self, link: &str, relative_to: &str) -> Uri {
let mut source = self.url.clone();
{
let mut segs = source.path_segments_mut().expect("path-based URL");
segs.pop_if_empty();
for s in relative_to.split('/').filter(|s| !s.is_empty()) {
segs.push(s);
}
segs.push("");
}
let mut resolved = source.join(link).expect("valid link");
let last = resolved.path_segments().and_then(|s| s.last()).unwrap_or("");
if !last.is_empty() && !last.ends_with(".md") {
let decoded = percent_decode_str(last).decode_utf8_lossy().into_owned();
resolved
.path_segments_mut()
.expect("path-based URL")
.pop()
.push(&format!("{}.md", decoded));
}
Uri::from_str(resolved.as_str()).expect("valid URI")
}
fn build_url(&self, key_or_path: &str) -> Uri {
let trimmed = key_or_path.trim_end_matches(".md");
let mut url = self.url.clone();
{
let mut segs = url.path_segments_mut().expect("path-based URL");
segs.pop_if_empty();
let parts: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
if let Some((last, rest)) = parts.split_last() {
segs.extend(rest);
segs.push(&format!("{}.md", last));
}
}
Uri::from_str(url.as_str()).expect("valid URI")
}
}
fn canonical(url: Url) -> Url {
if url.scheme() != "file" {
return url;
}
let was_directory = url.path().ends_with('/');
let Ok(path) = url.to_file_path() else {
return url;
};
let path = lowercase_drive_letter(&path);
let result = if was_directory {
Url::from_directory_path(&path)
} else {
Url::from_file_path(&path)
};
result.unwrap_or(url)
}
fn lowercase_drive_letter(path: &Path) -> PathBuf {
let s = path.to_string_lossy();
let (prefix, rest) = s.strip_prefix('/').map_or(("", s.as_ref()), |r| ("/", r));
let mut chars = rest.chars();
match (chars.next(), chars.next()) {
(Some(d), Some(':')) if d.is_ascii_alphabetic() => {
PathBuf::from(format!("{}{}{}", prefix, d.to_ascii_lowercase(), &rest[2..]))
}
_ => path.to_path_buf(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_url_to_key_with_danish_characters() {
let base_path = BasePath::new("file:///basepath/".to_string());
let uri = Uri::from_str("file:///basepath/t%C3%B8j.md").unwrap();
let key = base_path.url_to_key(&uri);
assert_eq!(key.to_string(), "tøj");
}
#[test]
fn test_url_to_key_with_regular_characters() {
let base_path = BasePath::new("file:///basepath/".to_string());
let uri = Uri::from_str("file:///basepath/regular.md").unwrap();
let key = base_path.url_to_key(&uri);
assert_eq!(key.to_string(), "regular");
}
#[test]
fn test_url_to_key_with_spaces_in_base_path() {
let base_path = BasePath::from_path("/path with spaces/docs");
let uri = Uri::from_str("file:///path%20with%20spaces/docs/test.md").unwrap();
let key = base_path.url_to_key(&uri);
assert_eq!(key.to_string(), "test");
}
#[test]
fn test_key_to_url_with_spaces_in_base_path() {
let base_path = BasePath::from_path("/path with spaces/docs");
let key = liwe::model::Key::name("test.md");
let url = base_path.key_to_url(&key);
assert_eq!(
url.to_string(),
"file:///path%20with%20spaces/docs/test.md"
);
}
#[test]
fn test_url_to_key_with_spaces_in_key() {
let base_path = BasePath::from_path("/basepath");
let uri = Uri::from_str("file:///basepath/my%20document.md").unwrap();
let key = base_path.url_to_key(&uri);
assert_eq!(key.to_string(), "my document");
}
#[test]
fn test_key_to_url_with_spaces_in_key() {
let base_path = BasePath::from_path("/basepath");
let key = liwe::model::Key::name("my document.md");
let url = base_path.key_to_url(&key);
assert_eq!(
url.to_string(),
"file:///basepath/my%20document.md"
);
}
#[test]
fn test_url_to_key_windows_case_and_percent_encoding_mismatch() {
let base_path = BasePath::new("file:///C:/base/".to_string());
let uri = Uri::from_str("file:///c%3A/base/one.md").unwrap();
let key = base_path.url_to_key(&uri);
assert_eq!(key.to_string(), "one");
}
#[test]
fn test_resolve_relative_url_doubles_md_suffix_when_link_has_md_extension() {
let base_path = BasePath::from_path("/basepath");
let url = base_path.resolve_relative_url("one.md", "");
assert_eq!(url.to_string(), "file:///basepath/one.md");
}
#[test]
fn test_name_to_url_with_md_suffix_in_name() {
let base_path = BasePath::from_path("/basepath");
let url = base_path.name_to_url("one.md");
assert_eq!(url.to_string(), "file:///basepath/one.md");
}
#[test]
fn test_resolve_relative_url_with_anchor_fragment() {
let base_path = BasePath::from_path("/basepath");
let url = base_path.resolve_relative_url("one#section", "");
assert_eq!(url.to_string(), "file:///basepath/one.md#section");
}
#[test]
fn test_from_rel_link_url_decodes_percent_encoded_spaces() {
let key = liwe::model::Key::from_rel_link_url("a%20b.md", "");
assert_eq!(key.to_string(), "a b");
}
#[test]
fn test_url_to_key_matches_parsed_reference_key() {
let base_path = BasePath::new("file:///C:/base/".to_string());
let source_uri = Uri::from_str("file:///c%3A/base/one.md").unwrap();
let target_uri = Uri::from_str("file:///c%3A/base/two.md").unwrap();
let source_key = base_path.url_to_key(&source_uri);
let target_key = base_path.url_to_key(&target_uri);
let parsed_target_key =
liwe::model::Key::from_rel_link_url("two", &source_key.parent());
assert_eq!(parsed_target_key.to_string(), target_key.to_string());
}
}