textlane_core/helpers/
url.rs1#[derive(Clone, Debug, PartialEq, Eq)]
3pub struct ResolvedUrl<'u> {
4 pub file: String,
6 pub suffix: &'u str,
8 pub directory: bool,
10}
11
12pub fn resolve_url<'u>(
17 url: &'u str,
18 path: Option<&str>,
19 root: Option<&str>,
20) -> Option<ResolvedUrl<'u>> {
21 if url.is_empty()
24 || url.starts_with('#')
25 || url.starts_with("//")
26 || url.contains('\\')
27 || url.chars().any(char::is_control)
28 || url.trim() != url
29 || has_scheme(url)
30 {
31 return None;
32 }
33 let end = url.find(['?', '#']).unwrap_or(url.len());
34 let (target, suffix) = url.split_at(end);
35 if target.is_empty() {
36 return None;
37 }
38 let file = match target.strip_prefix('/') {
39 Some(absolute) => normalize_path(absolute)?,
40 None => {
41 let directory = parent_of(&normalize_path(&slashes(path?))?);
42 let joined = normalize_path(&format!("{directory}/{target}"))?;
43 relative_to(&joined, root)?
44 }
45 };
46 Some(ResolvedUrl {
47 file,
48 suffix,
49 directory: names_directory(target),
50 })
51}
52
53pub fn prefix_base(url: &str, base: Option<&str>) -> String {
55 let base = base
56 .map(|base| base.trim_end_matches('/'))
57 .unwrap_or_default();
58 if base.is_empty() || url == base || url.starts_with(&format!("{base}/")) {
59 return url.to_owned();
60 }
61 format!("{base}{url}")
62}
63
64fn has_scheme(url: &str) -> bool {
66 let Some(colon) = url.find(':') else {
67 return false;
68 };
69 let scheme = &url.as_bytes()[..colon];
70 scheme.first().is_some_and(u8::is_ascii_alphabetic)
71 && scheme
72 .iter()
73 .all(|byte| byte.is_ascii_alphanumeric() || b"+-.".contains(byte))
74}
75
76fn names_directory(target: &str) -> bool {
78 target.ends_with('/') || matches!(target.rsplit('/').next(), Some("." | ".."))
79}
80
81fn slashes(path: &str) -> String {
82 path.replace('\\', "/")
83}
84
85fn normalize_path(path: &str) -> Option<String> {
88 let absolute = path.starts_with('/');
89 let mut segments: Vec<&str> = Vec::new();
90 for segment in path.split('/') {
91 match segment {
92 "" | "." => {}
93 ".." => {
94 segments.pop()?;
95 }
96 segment => segments.push(segment),
97 }
98 }
99 let joined = segments.join("/");
100 Some(if absolute {
101 format!("/{joined}")
102 } else {
103 joined
104 })
105}
106
107fn parent_of(path: &str) -> String {
108 match path.rfind('/') {
109 Some(slash) => path[..slash].to_owned(),
110 None => String::new(),
111 }
112}
113
114fn relative_to(path: &str, root: Option<&str>) -> Option<String> {
116 let Some(root) = root else {
117 return Some(path.trim_start_matches('/').to_owned());
118 };
119 let root = normalize_path(&slashes(root))?;
120 if root.is_empty() || root == "/" {
121 return Some(path.trim_start_matches('/').to_owned());
122 }
123 let inside = path.strip_prefix(&root)?;
124 match inside.strip_prefix('/') {
125 Some(inside) => Some(inside.to_owned()),
126 None if inside.is_empty() => Some(String::new()),
127 None => None,
128 }
129}
130
131#[cfg(test)]
132#[path = "url_unit.rs"]
133mod unit;