/// Path parsing utilities
/// path = path-abempty / path-absolute / path-noscheme / path-rootless / path-empty
///| Validate a path segment: *pchar
fn is_valid_segment(s : String) -> Bool {
is_valid_encoded_string(s, is_pchar)
}
///| Validate a non-zero segment: 1*pchar
fn is_valid_segment_nz(s : String) -> Bool {
not(s.is_empty()) && is_valid_segment(s)
}
///| Validate a segment-nz-nc: 1*( unreserved / pct-encoded / sub-delims / "@" )
/// (non-zero-length segment without any colon ":")
fn is_valid_segment_nz_nc(s : String) -> Bool {
not(s.is_empty()) && is_valid_encoded_string(s, is_segment_nz_nc_char)
}
///| Determine path type and validate
pub fn classify_and_validate_path(
path : String
) -> URIResult[(PathType, String)] {
if path.is_empty() {
// path-empty
Ok((Empty, path))
} else if path.has_prefix("//") {
// This would be authority, not a valid path by itself
Err(InvalidPath("Path cannot start with '//' (use authority component)"))
} else if path.has_prefix("/") {
// path-absolute or path-abempty
validate_path_absolute(path)
} else {
// path-noscheme or path-rootless
validate_path_relative(path)
}
}
///| Validate path-absolute: "/" [ segment-nz *( "/" segment ) ]
fn validate_path_absolute(path : String) -> URIResult[(PathType, String)] {
if path == "/" {
Ok((Absolute, path))
} else {
let segments = path.substring(start=1).split("/") // Remove leading '/'
let segments_arr = segments.collect()
// First segment must be non-zero if it exists
if segments_arr.length() > 0 && not(segments_arr[0].to_string().is_empty()) {
if not(is_valid_segment_nz(segments_arr[0].to_string())) {
return Err(InvalidPath("Invalid first segment in absolute path"))
}
}
// Remaining segments can be empty
for i = 1; i < segments_arr.length(); i = i + 1 {
if not(is_valid_segment(segments_arr[i].to_string())) {
return Err(InvalidPath("Invalid segment in absolute path"))
}
}
Ok((Absolute, path))
}
}
///| Validate relative paths (path-noscheme or path-rootless)
fn validate_path_relative(path : String) -> URIResult[(PathType, String)] {
let segments = path.split("/")
let segments_arr = segments.collect()
if segments_arr.length() == 0 {
return Err(InvalidPath("Path cannot be empty here"))
}
// First segment determines if this is noscheme or rootless
let first_segment = segments_arr[0].to_string()
if not(is_valid_segment_nz(first_segment)) {
return Err(InvalidPath("Invalid first segment in relative path"))
}
// Check if first segment contains colon (which would make it path-rootless)
let has_colon = first_segment.contains(":")
let path_type = if has_colon { Rootless } else { NoScheme }
if path_type == NoScheme {
// For path-noscheme, first segment must be segment-nz-nc
if not(is_valid_segment_nz_nc(first_segment)) {
return Err(
InvalidPath("First segment in no-scheme path cannot contain colon"),
)
}
}
// Validate remaining segments
for i = 1; i < segments_arr.length(); i = i + 1 {
if not(is_valid_segment(segments_arr[i].to_string())) {
return Err(InvalidPath("Invalid segment in relative path"))
}
}
Ok((path_type, path))
}
///| Validate path-abempty: *( "/" segment )
pub fn validate_path_abempty(path : String) -> URIResult[String] {
if path.is_empty() {
Ok(path)
} else if not(path.has_prefix("/")) {
Err(InvalidPath("Path-abempty must start with '/' or be empty"))
} else {
let segments = path.substring(start=1).split("/") // Remove leading '/'
let segments_arr = segments.collect()
for i = 0; i < segments_arr.length(); i = i + 1 {
if not(is_valid_segment(segments_arr[i].to_string())) {
return Err(InvalidPath("Invalid segment in abempty path"))
}
}
Ok(path)
}
}
///| Normalize path by removing unnecessary components
pub fn normalize_path(path : String) -> String {
if path.is_empty() {
return path
}
let is_absolute = path.has_prefix("/")
let segments = (if is_absolute { path.substring(start=1) } else { path }).split(
"/",
)
let segments_arr = segments.collect()
let normalized_segments : Array[String] = []
for i = 0; i < segments_arr.length(); i = i + 1 {
let segment = segments_arr[i].to_string()
if segment == "." || segment.is_empty() {
// Skip current directory and empty segments (except for empty first segment in absolute paths)
if i == 0 && is_absolute {
normalized_segments.push("")
}
continue
} else if segment == ".." {
// Parent directory
if normalized_segments.length() > 0 &&
normalized_segments[normalized_segments.length() - 1] != ".." &&
not(normalized_segments[normalized_segments.length() - 1].is_empty()) {
ignore(normalized_segments.pop())
} else if not(is_absolute) {
// Keep ".." in relative paths if we can't resolve it
normalized_segments.push(segment)
}
// For absolute paths, ".." at root is ignored
} else {
normalized_segments.push(segment)
}
}
let result = normalized_segments.join("/")
if is_absolute && not(result.has_prefix("/")) {
"/" + result
} else {
result
}
}
///| Join two paths
pub fn join_paths(base : String, path : String) -> String {
if path.has_prefix("/") {
// Absolute path replaces base
path
} else if base.is_empty() {
path
} else if path.is_empty() {
base
} else {
let normalized_base = if base.has_suffix("/") { base } else { base + "/" }
normalize_path(normalized_base + path)
}
}
///| Split path into segments
pub fn path_segments(path : String) -> Array[String] {
if path.is_empty() {
[]
} else if path == "/" {
[""]
} else {
let trimmed = if path.has_prefix("/") {
path.substring(start=1)
} else {
path
}
trimmed.split("/").map(fn(s) { s.to_string() }).collect()
}
}