Skip to main content

iri_rs_core/
components.rs

1//! Helper views over path / authority components, and convenience accessors.
2
3use std::ops::Deref;
4
5use crate::types::{Iri, IriRef, Uri, UriRef};
6
7#[inline]
8#[must_use]
9pub fn path_segments(path: &str) -> PathSegments<'_> {
10    let rest = path.strip_prefix('/').unwrap_or(path);
11    PathSegments {
12        inner: if rest.is_empty() { None } else { Some(rest.split('/')) },
13    }
14}
15
16pub struct PathSegments<'a> {
17    inner: Option<std::str::Split<'a, char>>,
18}
19
20impl<'a> Iterator for PathSegments<'a> {
21    type Item = &'a str;
22    #[inline]
23    fn next(&mut self) -> Option<&'a str> {
24        self.inner.as_mut()?.next()
25    }
26}
27
28impl<'a> DoubleEndedIterator for PathSegments<'a> {
29    #[inline]
30    fn next_back(&mut self) -> Option<&'a str> {
31        self.inner.as_mut()?.next_back()
32    }
33}
34
35#[inline]
36#[must_use]
37pub fn path_is_absolute(path: &str) -> bool {
38    path.starts_with('/')
39}
40
41/// Applies RFC 3986 §5.2.4 `remove_dot_segments` to a standalone path.
42///
43/// This differs from the resolution-time pass in [`crate::resolve`] in how it
44/// treats a `..` with nothing left to climb over. Resolution always runs
45/// against a base, so such a `..` is spent and discarded. A standalone relative
46/// path has no base yet, and the `..` still applies to whatever it is later
47/// resolved against — dropping it would make `../a` and `a` normalize alike.
48/// It is therefore preserved (`a/../..` → `../`), while on an absolute path the
49/// root absorbs it (`/a/../..` → `/`) as usual.
50///
51/// A leading empty segment is likewise kept: `//a` and `/a` name different
52/// resources under an authority, so collapsing them would make distinct IRIs
53/// compare equal.
54///
55/// Nothing here guards a first segment holding a `:`, unlike upstream `iref`'s
56/// context-free `Path::normalized`. This runs on the path of a *parsed* IRI,
57/// whose scheme has already settled the ambiguity — guarding `urn:isbn:0451`'s
58/// `isbn:0451` would rewrite a path that was never in doubt, and force every
59/// `urn:`/`tag:`/`mailto:` comparison through an allocation. The two places
60/// that do drop the context, recomposition in [`crate::mutate`] and
61/// [`crate::relativize`], add the `./` themselves via the crate-internal
62/// `first_segment_has_colon`.
63#[must_use]
64pub fn normalize_path(path: &str) -> String {
65    if path_is_normalized(path) {
66        return path.to_owned();
67    }
68    let absolute = path.starts_with('/');
69    let mut input = path;
70    let mut out = String::with_capacity(path.len());
71    // Bytes of `out` holding `..` segments this path could not resolve away. A
72    // later `..` must not pop them, or `../..` would collapse back to `../`.
73    let mut floor = 0usize;
74
75    // Cheap prefix tests come first and `memchr` runs only for a real segment:
76    // dot segments are short, and a SIMD scan costs more to set up than the
77    // comparisons that recognise them.
78    while !input.is_empty() {
79        if input.starts_with("../") {
80            // Leading `..` on a relative path — nothing precedes it to remove,
81            // so `pop_segment` preserves it. Consume only the dots and leave
82            // the `/` to separate it from whatever follows.
83            input = &input[2..];
84            pop_segment(&mut out, &mut floor, absolute);
85        } else if let Some(rest) = input.strip_prefix("./") {
86            input = rest;
87        } else if input.starts_with("/./") {
88            input = &input[2..];
89        } else if input == "/." {
90            input = "/";
91        } else if input.starts_with("/../") {
92            input = &input[3..];
93            pop_segment(&mut out, &mut floor, absolute);
94        } else if input == "/.." || input == ".." {
95            pop_segment(&mut out, &mut floor, absolute);
96            input = close_directory(&out, absolute);
97        } else if input == "." {
98            input = "";
99        } else {
100            let rest = if let Some(r) = input.strip_prefix('/') {
101                // A relative path just emptied by `..` must not pick up a
102                // leading `/` and turn absolute.
103                if absolute || !out.is_empty() {
104                    out.push('/');
105                }
106                r
107            } else {
108                input
109            };
110            let end = memchr::memchr(b'/', rest.as_bytes()).unwrap_or(rest.len());
111            out.push_str(&rest[..end]);
112            input = &rest[end..];
113        }
114    }
115    out
116}
117
118/// A path ending in a dot segment names a directory and so keeps a trailing
119/// `/` — unless that would make an emptied relative path absolute.
120#[inline]
121fn close_directory(out: &str, absolute: bool) -> &'static str {
122    if out.is_empty() && !absolute { "" } else { "/" }
123}
124
125/// Whether [`normalize_path`] would return `path` unchanged.
126///
127/// The single source of truth for that question: callers on the comparison and
128/// hashing paths use it to skip the allocation, and they must agree with
129/// [`normalize_path`] exactly or equal IRIs would hash apart.
130#[inline]
131pub(crate) fn path_is_normalized(path: &str) -> bool {
132    memchr::memchr(b'.', path.as_bytes()).is_none()
133}
134
135/// Whether `path` is a relative path whose first segment holds a `:`, which
136/// RFC 3986 §4.2 would read as a scheme unless a `./` precedes it.
137///
138/// An absolute path — every path under an authority — settles on the first
139/// byte, so this costs a single comparison on the common path.
140#[inline]
141pub(crate) fn first_segment_has_colon(path: &str) -> bool {
142    if path.starts_with('/') {
143        return false;
144    }
145    let first_segment = match memchr::memchr(b'/', path.as_bytes()) {
146        Some(slash) => &path[..slash],
147        None => path,
148    };
149    memchr::memchr(b':', first_segment.as_bytes()).is_some()
150}
151
152fn pop_segment(out: &mut String, floor: &mut usize, absolute: bool) {
153    if out.len() > *floor {
154        match memchr::memrchr(b'/', &out.as_bytes()[*floor..]) {
155            Some(slash) => out.truncate(*floor + slash),
156            None => out.truncate(*floor),
157        }
158        return;
159    }
160    // Nothing left to climb over. On a relative path the `..` still applies to
161    // whatever base the path is resolved against, so it has to survive; an
162    // absolute path's root absorbs it.
163    if !absolute {
164        if *floor > 0 {
165            out.push('/');
166        }
167        out.push_str("..");
168        *floor = out.len();
169    }
170}
171
172#[must_use]
173pub fn split_authority(authority: &str) -> (Option<&str>, &str, Option<&str>) {
174    let (user_info, rest) = match memchr::memchr(b'@', authority.as_bytes()) {
175        Some(i) => (Some(&authority[..i]), &authority[i + 1..]),
176        None => (None, authority),
177    };
178    let (host, port) = if let Some(rest_in) = rest.strip_prefix('[') {
179        if let Some(end) = memchr::memchr(b']', rest_in.as_bytes()) {
180            let host = &rest[..end + 2];
181            let tail = &rest[end + 2..];
182            match tail.strip_prefix(':') {
183                Some(p) => (host, Some(p)),
184                None => (host, None),
185            }
186        } else {
187            (rest, None)
188        }
189    } else {
190        match memchr::memchr(b':', rest.as_bytes()) {
191            Some(i) => (&rest[..i], Some(&rest[i + 1..])),
192            None => (rest, None),
193        }
194    };
195    (user_info, host, port)
196}
197
198impl<T: Deref<Target = str>> Iri<T> {
199    pub fn path_segments(&self) -> PathSegments<'_> {
200        path_segments(self.path())
201    }
202
203    pub fn authority_parts(&self) -> Option<(Option<&str>, &str, Option<&str>)> {
204        self.authority().map(split_authority)
205    }
206}
207
208impl<T: Deref<Target = str>> IriRef<T> {
209    pub fn path_segments(&self) -> PathSegments<'_> {
210        path_segments(self.path())
211    }
212    pub fn authority_parts(&self) -> Option<(Option<&str>, &str, Option<&str>)> {
213        self.authority().map(split_authority)
214    }
215}
216
217impl<T: Deref<Target = str>> Uri<T> {
218    pub fn path_segments(&self) -> PathSegments<'_> {
219        path_segments(self.path())
220    }
221    pub fn authority_parts(&self) -> Option<(Option<&str>, &str, Option<&str>)> {
222        self.authority().map(split_authority)
223    }
224}
225
226impl<T: Deref<Target = str>> UriRef<T> {
227    pub fn path_segments(&self) -> PathSegments<'_> {
228        path_segments(self.path())
229    }
230    pub fn authority_parts(&self) -> Option<(Option<&str>, &str, Option<&str>)> {
231        self.authority().map(split_authority)
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn segments_absolute() {
241        let segs: Vec<&str> = path_segments("/a/b/c").collect();
242        assert_eq!(segs, vec!["a", "b", "c"]);
243    }
244
245    #[test]
246    fn segments_relative() {
247        let segs: Vec<&str> = path_segments("a/b/c").collect();
248        assert_eq!(segs, vec!["a", "b", "c"]);
249    }
250
251    #[test]
252    fn segments_trailing_slash() {
253        let segs: Vec<&str> = path_segments("/a/b/").collect();
254        assert_eq!(segs, vec!["a", "b", ""]);
255    }
256
257    #[test]
258    fn split_auth_host_only() {
259        assert_eq!(split_authority("example.com"), (None, "example.com", None));
260    }
261
262    #[test]
263    fn split_auth_full() {
264        assert_eq!(split_authority("user:pass@host:80"), (Some("user:pass"), "host", Some("80")));
265    }
266
267    #[test]
268    fn split_auth_ipv6() {
269        assert_eq!(split_authority("[::1]:8080"), (None, "[::1]", Some("8080")));
270    }
271
272    #[test]
273    fn normalize_basic() {
274        assert_eq!(normalize_path("/a/b/../c"), "/a/c");
275        assert_eq!(normalize_path("a/./b/../c"), "a/c");
276    }
277
278    #[test]
279    fn normalize_absolute_paths() {
280        for (input, expected) in [
281            ("/", "/"),
282            ("/.", "/"),
283            ("/./", "/"),
284            ("/..", "/"),
285            ("/a/..", "/"),
286            ("/a/./", "/a/"),
287            ("/a/../..", "/"),
288            ("/../..", "/"),
289            ("/a/b/c/../../d", "/a/d"),
290        ] {
291            assert_eq!(normalize_path(input), expected, "path `{input}`");
292        }
293    }
294
295    /// A `..` with nothing left to climb over survives on a relative path: it
296    /// still applies to whatever base the path is later resolved against.
297    #[test]
298    fn normalize_keeps_unresolvable_parent_segments_on_relative_paths() {
299        for (input, expected) in [
300            ("..", "../"),
301            ("../", "../"),
302            ("../a", "../a"),
303            ("../..", "../../"),
304            ("../../", "../../"),
305            ("a/../..", "../"),
306            ("../a/..", "../"),
307            ("a/../../b", "../b"),
308        ] {
309            assert_eq!(normalize_path(input), expected, "path `{input}`");
310        }
311    }
312
313    #[test]
314    fn normalize_relative_paths() {
315        for (input, expected) in [
316            ("", ""),
317            (".", ""),
318            ("./", ""),
319            ("./a", "a"),
320            ("a/..", ""),
321            ("a/.", "a/"),
322            ("a/b/..", "a/"),
323            ("a/b/../", "a/"),
324            ("a/b/c/..", "a/b/"),
325            ("a/b/c/.", "a/b/c/"),
326            ("a/../b", "b"),
327            ("x/./y/.././z", "x/z"),
328        ] {
329            assert_eq!(normalize_path(input), expected, "path `{input}`");
330        }
331    }
332
333    /// Empty segments name real path components: `//a` and `/a` are different
334    /// resources under an authority, so normalization must not merge them.
335    #[test]
336    fn normalize_preserves_empty_segments() {
337        for (input, expected) in [
338            ("a//b", "a//b"),
339            ("a//../b", "a/b"),
340            ("//a", "//a"),
341            ("/.//a", "//a"),
342            ("/.//", "//"),
343            ("/a/..//b", "//b"),
344        ] {
345            assert_eq!(normalize_path(input), expected, "path `{input}`");
346        }
347    }
348
349    /// A `:` in the first segment is left alone: this normalizes the path of an
350    /// already-parsed IRI, where the scheme has settled what the segment is.
351    /// Guarding it here would rewrite unambiguous `urn:`/`tag:` paths and cost
352    /// an allocation on every such comparison; the callers that genuinely lose
353    /// the context add the `./` themselves.
354    #[test]
355    fn normalize_leaves_a_first_segment_colon_alone() {
356        for (input, expected) in [
357            ("a:b", "a:b"),
358            ("./a:b", "a:b"),
359            ("x/../a:b", "a:b"),
360            ("a/b:c", "a/b:c"),
361            ("./a/b:c", "a/b:c"),
362            ("/a:b", "/a:b"),
363            // The corpus paths this keeps off the allocating path.
364            ("isbn:0451450523", "isbn:0451450523"),
365            ("example.com,2026-01-01:foo/bar", "example.com,2026-01-01:foo/bar"),
366        ] {
367            assert_eq!(normalize_path(input), expected, "path `{input}`");
368        }
369    }
370
371    /// The guard still exists for callers that do need it.
372    #[test]
373    fn first_segment_colon_is_detectable() {
374        for (path, expected) in [("a:b", true), ("a/b:c", false), ("/a:b", false), ("", false), ("a", false)] {
375            assert_eq!(first_segment_has_colon(path), expected, "path `{path}`");
376        }
377    }
378
379    #[test]
380    fn normalize_is_idempotent() {
381        for input in [
382            "",
383            "/",
384            ".",
385            "..",
386            "./",
387            "../",
388            "./a",
389            "../a",
390            "a/..",
391            "a/../..",
392            "/a/../..",
393            "/..",
394            "/./",
395            "a/b/..",
396            "a//b",
397            "/.//",
398            "a:b",
399            "./a:b",
400            "x/./y/.././z",
401        ] {
402            let once = normalize_path(input);
403            assert_eq!(normalize_path(&once), once, "path `{input}`");
404        }
405    }
406}