///|
pub(all) enum PathSourceKind {
Empty
BarePath
AbsolutePath
AbsoluteUrl
} derive(Eq, Debug)
///|
pub(all) struct PathSnapshot {
original : String
normalized : String
source : PathSourceKind
query : String?
fragment : String?
had_query : Bool
had_fragment : Bool
collapsed_slashes : Bool
removed_dot_segments : Bool
root_fallback : Bool
} derive(Eq, Debug)
///|
pub(all) struct PathReferenceParts {
source : PathSourceKind
path : String
query : String?
fragment : String?
had_query : Bool
had_fragment : Bool
} derive(Eq, Debug)
///|
struct QueryFragmentParts {
path : String
query : String?
fragment : String?
had_query : Bool
had_fragment : Bool
} derive(Eq, Debug)
///|
pub(all) struct NormalizedPathBody {
normalized : String
segments : Array[String]
collapsed_slashes : Bool
removed_dot_segments : Bool
root_fallback : Bool
} derive(Eq, Debug)
///|
pub fn PathSourceKind::label(self : PathSourceKind) -> String {
match self {
Empty => "empty"
BarePath => "bare-path"
AbsolutePath => "absolute-path"
AbsoluteUrl => "absolute-url"
}
}
///|
pub fn normalize_target(input : StringView) -> PathSnapshot {
let original = input.to_owned()
let trimmed = input.trim()
if trimmed.is_empty() {
{
original,
normalized: "/",
source: Empty,
query: None,
fragment: None,
had_query: false,
had_fragment: false,
collapsed_slashes: false,
removed_dot_segments: false,
root_fallback: true,
}
} else {
let parsed = split_reference(trimmed)
let normalized_path = normalize_path_body(parsed.path)
{
original,
normalized: normalized_path.normalized,
source: parsed.source,
query: parsed.query,
fragment: parsed.fragment,
had_query: parsed.had_query,
had_fragment: parsed.had_fragment,
collapsed_slashes: normalized_path.collapsed_slashes,
removed_dot_segments: normalized_path.removed_dot_segments,
root_fallback: normalized_path.root_fallback,
}
}
}
///|
pub fn extract_path(input : StringView) -> String {
normalize_target(input).normalized
}
///|
pub fn path_segments(path : StringView) -> Array[String] {
let normalized = normalize_path_body(path)
normalized.segments
}
///|
pub fn path_depth(path : StringView) -> Int {
path_segments(path).length()
}
///|
pub fn path_leaf(path : StringView) -> String {
let segments = path_segments(path)
if segments.is_empty() {
"/"
} else {
segments[segments.length() - 1]
}
}
///|
pub fn path_parent(path : StringView) -> String {
let segments = path_segments(path)
if segments.length() <= 1 {
"/"
} else {
let parent : Array[String] = []
for index, segment in segments.iter2() {
if index < segments.length() - 1 {
parent.push(segment)
}
}
join_segments(parent, false)
}
}
///|
pub fn path_has_trailing_slash(path : StringView) -> Bool {
let text = path.trim()
text.has_suffix("/")
}
///|
pub fn path_without_trailing_slash(path : StringView) -> String {
let text = normalize_target(path).normalized
if text == "/" {
text
} else {
trim_trailing_slash(text)
}
}
///|
pub fn path_extension(path : StringView) -> String? {
let leaf = path_leaf(path)
match leaf.split_once(".") {
Some((_, ext)) => if ext.is_empty() { None } else { Some(ext.to_owned()) }
None => None
}
}
///|
pub fn path_starts_with_segment(
path : StringView,
segment : StringView,
) -> Bool {
let needle = segment.trim()
if needle.is_empty() {
false
} else {
let segments = path_segments(path)
!segments.is_empty() && segments[0] == needle.to_owned()
}
}
///|
pub fn path_contains_segment(path : StringView, segment : StringView) -> Bool {
let needle = segment.trim()
if needle.is_empty() {
false
} else {
let segments = path_segments(path)
segments.any(part => part == needle.to_owned())
}
}
///|
pub fn path_join(segments : Array[String]) -> String {
join_segments(segments, false)
}
///|
pub fn PathSnapshot::as_path(self : PathSnapshot) -> String {
self.normalized
}
///|
pub fn PathSnapshot::to_line(self : PathSnapshot) -> String {
let parts : Array[String] = []
parts.push(self.source.label())
parts.push(self.normalized)
if self.had_query {
match self.query {
Some(query) => parts.push("?" + query)
None => ()
}
}
if self.had_fragment {
match self.fragment {
Some(fragment) => parts.push("#" + fragment)
None => ()
}
}
parts.join(" ")
}
///|
pub fn PathSnapshot::to_markdown(self : PathSnapshot) -> String {
let lines : Array[String] = []
lines.push("- Source: " + self.source.label())
lines.push("- Normalized: " + self.normalized)
lines.push("- Original: " + self.original)
lines.push("- Query present: " + bool_label(self.had_query))
lines.push("- Fragment present: " + bool_label(self.had_fragment))
lines.push("- Collapsed slashes: " + bool_label(self.collapsed_slashes))
lines.push("- Removed dot segments: " + bool_label(self.removed_dot_segments))
lines.push("- Root fallback: " + bool_label(self.root_fallback))
lines.join("\n")
}
///|
pub fn PathSnapshot::is_root(self : PathSnapshot) -> Bool {
self.normalized == "/"
}
///|
pub fn PathSnapshot::has_query(self : PathSnapshot) -> Bool {
self.had_query
}
///|
pub fn PathSnapshot::has_fragment(self : PathSnapshot) -> Bool {
self.had_fragment
}
///|
pub fn PathSnapshot::debug_flags(self : PathSnapshot) -> String {
let flags : Array[String] = []
if self.had_query {
flags.push("query")
}
if self.had_fragment {
flags.push("fragment")
}
if self.collapsed_slashes {
flags.push("collapsed-slashes")
}
if self.removed_dot_segments {
flags.push("dot-segments")
}
if self.root_fallback {
flags.push("root-fallback")
}
if flags.is_empty() {
"stable"
} else {
flags.join(",")
}
}
///|
fn split_reference(input : StringView) -> PathReferenceParts {
let trimmed = input.trim()
if trimmed.is_empty() {
{
source: Empty,
path: "/",
query: None,
fragment: None,
had_query: false,
had_fragment: false,
}
} else if trimmed.has_prefix("http://") || trimmed.has_prefix("https://") {
let after_scheme = match trimmed.split_once("://") {
Some((_, rest)) => rest
None => trimmed
}
let after_host = match after_scheme.split_once("/") {
Some((_, rest)) => rest
None => ""
}
let parts = split_query_fragment(after_host)
{
source: AbsoluteUrl,
path: parts.path,
query: parts.query,
fragment: parts.fragment,
had_query: parts.had_query,
had_fragment: parts.had_fragment,
}
} else if trimmed.has_prefix("/") {
let parts = split_query_fragment(trimmed)
{
source: AbsolutePath,
path: parts.path,
query: parts.query,
fragment: parts.fragment,
had_query: parts.had_query,
had_fragment: parts.had_fragment,
}
} else {
let parts = split_query_fragment(trimmed)
{
source: BarePath,
path: "/" + parts.path,
query: parts.query,
fragment: parts.fragment,
had_query: parts.had_query,
had_fragment: parts.had_fragment,
}
}
}
///|
fn split_query_fragment(input : StringView) -> QueryFragmentParts {
let mut path = input
let mut query : String? = None
let mut fragment : String? = None
let mut had_query = false
let mut had_fragment = false
match path.split_once("#") {
Some((left, right)) => {
had_fragment = true
fragment = Some(right.to_owned())
path = left
}
None => ()
}
match path.split_once("?") {
Some((left, right)) => {
had_query = true
query = Some(right.to_owned())
path = left
}
None => ()
}
{ path: path.to_owned(), query, fragment, had_query, had_fragment }
}
///|
fn normalize_path_body(input : StringView) -> NormalizedPathBody {
let text = input.trim()
if text.is_empty() {
{
normalized: "/",
segments: [],
collapsed_slashes: false,
removed_dot_segments: false,
root_fallback: true,
}
} else {
let raw_segments = text.split("/")
let mut collapsed_slashes = false
let mut removed_dot_segments = false
let mut stack : Array[String] = []
let mut seen_segment = false
let mut previous_empty = false
let mut leading_empty_count = 0
for segment in raw_segments {
let part = segment.trim()
if part.is_empty() {
if seen_segment && previous_empty {
collapsed_slashes = true
} else if !seen_segment {
leading_empty_count = leading_empty_count + 1
if leading_empty_count > 1 {
collapsed_slashes = true
}
}
if seen_segment {
previous_empty = true
}
} else if part == "." {
removed_dot_segments = true
seen_segment = true
previous_empty = false
} else if part == ".." {
removed_dot_segments = true
stack = drop_last_segment(stack)
seen_segment = true
previous_empty = false
} else {
if seen_segment && previous_empty {
collapsed_slashes = true
}
stack.push(part.to_owned())
seen_segment = true
previous_empty = false
}
}
let trailing_slash = text.has_suffix("/") && !stack.is_empty()
let normalized = join_segments(stack, trailing_slash)
{
normalized,
segments: stack,
collapsed_slashes,
removed_dot_segments,
root_fallback: normalized == "/",
}
}
}
///|
fn drop_last_segment(segments : Array[String]) -> Array[String] {
if segments.is_empty() {
segments
} else {
let result : Array[String] = []
let limit = segments.length() - 1
for index, segment in segments.iter2() {
if index < limit {
result.push(segment)
}
}
result
}
}
///|
fn join_segments(segments : Array[String], trailing_slash : Bool) -> String {
if segments.is_empty() {
"/"
} else {
let body = segments.join("/")
if trailing_slash {
"/" + body + "/"
} else {
"/" + body
}
}
}
///|
fn trim_trailing_slash(path : String) -> String {
if path == "/" {
path
} else if path.has_suffix("/") {
match path.strip_suffix("/") {
Some(body) => trim_trailing_slash(body.to_owned())
None => path
}
} else {
path
}
}
///|
fn bool_label(value : Bool) -> String {
if value {
"yes"
} else {
"no"
}
}