Skip to content

Commit 4cf136c

Browse files
Merge pull request #547 from roc-lang/fix/issue-537
Add representation-preserving Path.parent and ancestors
2 parents 8fa8f72 + ddb8f6b commit 4cf136c

3 files changed

Lines changed: 262 additions & 2 deletions

File tree

‎examples/path.roc‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
## Inspect a path's filename, extension, string representation, and type (file/dir/symlink).
1+
## Inspect a path's parent, filename, extension, and type (file/dir/symlink).
22
app [main!] { pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.27.0/9yjxqdGTP4xSpSEmDazwTmRsehfRZzA19pwEKkR6fj1g.tar.zst" }
33

44
import pf.OsStr
@@ -8,12 +8,14 @@ import pf.Path
88
main! : List(OsStr) => Try({}, _)
99
main! = |args| {
1010
path = path_argument(args)?
11+
parent = path.parent().map_ok(Path.display) ?? "<none>"
1112
filename = Path.filename(path).map_ok(Path.display) ?? "Path is not a file."
1213
extension = Path.ext(path).map_ok(Path.display) ?? "<none>"
1314

1415
Stdout.line!(
1516
\\Path: ${Path.display(path)}
1617
\\Debug: ${Str.inspect(path)}
18+
\\Parent: ${parent}
1719
\\Filename: ${filename}
1820
\\Extension: ${extension}
1921
\\Type: ${Str.inspect(Path.type!(path)?)}

‎platform/Path.roc‎

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,43 @@ Path := [
392392
to_hash : Path, Hasher -> Hasher
393393
to_hash = |path, hasher| to_os_str(path).to_hash(hasher)
394394

395+
## Remove the final path component without accessing the filesystem. The
396+
## returned path preserves the input representation. UTF-8 and Unix paths
397+
## use `/`. Windows paths recognize drive and share roots and use both
398+
## separators, except inside verbatim paths, which use `\`.
399+
##
400+
## Trailing separators and `.` components are ignored, except for a leading
401+
## relative `.` and for `.` in a Windows verbatim path. `..` is kept as a
402+
## component. An empty path, root, or Windows prefix alone has `NoParent`.
403+
## The parent of a one-component relative path is the empty path.
404+
##
405+
## ```roc
406+
## expect Path.parent("src/main.roc") == Ok(Path.utf8("src"))
407+
## expect Path.parent("/src/") == Ok(Path.utf8("/"))
408+
## expect Path.parent("/") == Err(NoParent)
409+
## ```
410+
parent : Path -> Try(Path, [NoParent])
411+
parent = |path|
412+
match path {
413+
Utf8(str) => parent_slash_units(Str.to_utf8(str)).map_ok(|bytes| Utf8(str_from_valid_utf8(bytes)))
414+
UnixBytes(bytes) => parent_slash_units(bytes).map_ok(|parent| UnixBytes(parent))
415+
WindowsU16s(units) => parent_windows_units(units).map_ok(|parent| WindowsU16s(parent))
416+
}
417+
418+
## Iterate over this path and its successive [parent] paths, starting with
419+
## the original path and ending when there is no parent.
420+
ancestors : Path -> Iter(Path)
421+
ancestors = |path|
422+
Iter.custom(
423+
Ok(path),
424+
Err(Unknown),
425+
|next|
426+
match next {
427+
Ok(item) => Ok((item, parent(item)))
428+
Err(NoParent) => Err(NoMore)
429+
},
430+
)
431+
395432
## Returns everything after the last directory separator.
396433
filename : Path -> Try(Path, [IsDirPath, EndsInDots])
397434
filename = |path|
@@ -499,6 +536,135 @@ str_from_valid_utf8 = |bytes|
499536
}
500537
}
501538

539+
parent_slash_units : List(U8) -> Try(List(U8), [NoParent])
540+
parent_slash_units = |units| {
541+
is_sep = |unit| unit == '/'
542+
root_end = if is_sep(units.get(0) ?? '.') {
543+
1
544+
} else {
545+
0
546+
}
547+
parent_units(units, { prefix_end: 0, root_end, verbatim: False }, is_sep, '.')
548+
}
549+
550+
parent_windows_units : List(U16) -> Try(List(U16), [NoParent])
551+
parent_windows_units = |units| {
552+
{ end: prefix_end, verbatim } = windows_prefix(units)
553+
# A slash immediately after a verbatim drive prefix still denotes the root.
554+
root_end = if windows_separator(units.get(prefix_end) ?? '.') {
555+
prefix_end + 1
556+
} else {
557+
prefix_end
558+
}
559+
is_sep = |unit| unit == '\\' or (Bool.not(verbatim) and unit == '/')
560+
parent_units(units, { prefix_end, root_end, verbatim }, is_sep, '.')
561+
}
562+
563+
# Look backward for the last component, then trim the separators and ignored
564+
# dots before it. Keep the root or prefix intact, as well as a leading `.`.
565+
parent_units : List(unit), { prefix_end : U64, root_end : U64, verbatim : Bool }, (unit -> Bool), unit -> Try(List(unit), [NoParent])
566+
where [unit.is_eq : unit, unit -> Bool]
567+
parent_units = |units, { prefix_end, root_end, verbatim }, is_sep, dot| {
568+
is_sep_at = |index| is_sep(units.get(index) ?? dot)
569+
is_dot_at = |index| units.get(index) == Ok(dot)
570+
len = units.len()
571+
leading_dot = root_end == prefix_end and is_dot_at(root_end) and (len == root_end + 1 or is_sep_at(root_end + 1))
572+
body_start = if leading_dot {
573+
root_end + 1
574+
} else {
575+
root_end
576+
}
577+
trim_end = |end| {
578+
var $end = end
579+
while $end > body_start {
580+
last = $end - 1
581+
if is_sep_at(last) {
582+
$end = last
583+
} else if Bool.not(verbatim) and is_dot_at(last) and (last == body_start or is_sep_at(last - 1)) {
584+
$end = last
585+
} else {
586+
break
587+
}
588+
}
589+
$end
590+
}
591+
592+
var $end = trim_end(len)
593+
if $end == body_start {
594+
if leading_dot and prefix_end == 0 {
595+
Ok([])
596+
} else {
597+
Err(NoParent)
598+
}
599+
} else {
600+
while $end > body_start and Bool.not(is_sep_at($end - 1)) {
601+
$end = $end - 1
602+
}
603+
Ok(units.take_first(trim_end($end)))
604+
}
605+
}
606+
607+
windows_separator : U16 -> Bool
608+
windows_separator = |unit| unit == '/' or unit == '\\'
609+
610+
windows_prefix : List(U16) -> { end : U64, verbatim : Bool }
611+
windows_prefix = |units|
612+
match units {
613+
['\\', '\\', '?', '\\', ..] => {
614+
backslash = |unit| unit == '\\'
615+
end = match units {
616+
['\\', '\\', '?', '\\', 'U', 'N', 'C', sep, ..] if windows_separator(sep) =>
617+
windows_share_end(units, 8, backslash).end
618+
['\\', '\\', '?', '\\', drive, ':', ..] if is_drive_letter(drive) and (units.len() == 6 or windows_separator(units.get(6) ?? '.')) => 6
619+
_ => windows_component_end(units, 4, backslash)
620+
}
621+
{ end, verbatim: True }
622+
}
623+
[first, second, '.', fourth, ..] if windows_separator(first) and windows_separator(second) and windows_separator(fourth) =>
624+
{ end: windows_component_end(units, 4, windows_separator), verbatim: False }
625+
[first, second, ..] if windows_separator(first) and windows_separator(second) => {
626+
{ end, complete } = windows_share_end(units, 2, windows_separator)
627+
{
628+
end: if complete {
629+
end
630+
} else {
631+
0
632+
},
633+
verbatim: False,
634+
}
635+
}
636+
[drive, ':', ..] if is_drive_letter(drive) => { end: 2, verbatim: False }
637+
_ => { end: 0, verbatim: False }
638+
}
639+
640+
is_drive_letter : U16 -> Bool
641+
is_drive_letter = |unit| (unit >= 'A' and unit <= 'Z') or (unit >= 'a' and unit <= 'z')
642+
643+
windows_share_end : List(U16), U64, (U16 -> Bool) -> { end : U64, complete : Bool }
644+
windows_share_end = |units, start, is_sep| {
645+
server_end = windows_component_end(units, start, is_sep)
646+
if server_end == units.len() {
647+
{ end: server_end, complete: False }
648+
} else {
649+
share_start = server_end + 1
650+
share_end = windows_component_end(units, share_start, is_sep)
651+
if share_end == share_start {
652+
{ end: server_end, complete: False }
653+
} else {
654+
{ end: share_end, complete: server_end > start }
655+
}
656+
}
657+
}
658+
659+
windows_component_end : List(U16), U64, (U16 -> Bool) -> U64
660+
windows_component_end = |units, start, is_sep| {
661+
var $end = start
662+
while $end < units.len() and Bool.not(is_sep(units.get($end) ?? '\\')) {
663+
$end = $end + 1
664+
}
665+
$end
666+
}
667+
502668
ends_with_u8 : List(U8), U8 -> Bool
503669
ends_with_u8 = |list, suffix|
504670
match list {
@@ -850,6 +1016,97 @@ expect Path.to_raw(Path.join(Path.unix("a"), "b")) == UnixBytes([97, 47, 98])
8501016
expect Path.to_raw(Path.join(Path.windows("a"), "b")) == WindowsU16s([97, 92, 98])
8511017
expect Path.to_raw(Path.join(Path.utf8("a"), "b")) == Utf8("a/b")
8521018

1019+
## Parent traversal preserves roots, relative components, and retained spelling.
1020+
expect Path.parent("src/main.roc") == Ok(Path.utf8("src"))
1021+
expect Path.parent("main.roc") == Ok(Path.utf8(""))
1022+
expect Path.parent("") == Err(NoParent)
1023+
expect Path.parent("/") == Err(NoParent)
1024+
expect Path.parent("///") == Err(NoParent)
1025+
expect Path.parent("/src") == Ok(Path.utf8("/"))
1026+
expect Path.parent("///src/main.roc") == Ok(Path.utf8("///src"))
1027+
expect Path.parent("src///main.roc///") == Ok(Path.utf8("src"))
1028+
expect Path.parent("src/./main.roc/.//") == Ok(Path.utf8("src"))
1029+
expect Path.parent("src/./lib/main.roc") == Ok(Path.utf8("src/./lib"))
1030+
expect Path.parent(".") == Ok(Path.utf8(""))
1031+
expect Path.parent("././/") == Ok(Path.utf8(""))
1032+
expect Path.parent("./main.roc") == Ok(Path.utf8("."))
1033+
expect Path.parent("..") == Ok(Path.utf8(""))
1034+
expect Path.parent("src/..") == Ok(Path.utf8("src"))
1035+
expect Path.parent("src/../main.roc") == Ok(Path.utf8("src/.."))
1036+
expect Path.parent("/../main.roc") == Ok(Path.utf8("/.."))
1037+
expect Path.parent("src/dir../main.roc") == Ok(Path.utf8("src/dir.."))
1038+
expect Path.parent("src\\main.roc") == Ok(Path.utf8(""))
1039+
expect Path.parent(Path.unix("C:\\src\\main.roc")) == Ok(Path.unix(""))
1040+
1041+
expect {
1042+
checked_parent = |path| {
1043+
if path == "" {
1044+
return Err(EmptyInput)
1045+
}
1046+
parent = Path.parent
1047+
Ok(parent(path)?)
1048+
}
1049+
checked_parent("") == Err(EmptyInput) and checked_parent("/") == Err(NoParent) and checked_parent("src/main.roc") == Ok(Path.utf8("src"))
1050+
}
1051+
1052+
## UTF-8 boundaries, arbitrary Unix bytes, and unpaired Windows surrogates survive.
1053+
expect Path.parent(Path.utf8("🐦/é.roc")).map_ok(Path.to_raw) == Ok(Utf8("🐦"))
1054+
expect Path.parent(Path.unix_bytes([0xFF, '/', 0xFE])).map_ok(Path.to_raw) == Ok(UnixBytes([0xFF]))
1055+
expect Path.parent(Path.unix_bytes([0xFF])).map_ok(Path.to_raw) == Ok(UnixBytes([]))
1056+
expect Path.parent(Path.windows_u16s([0xD800, '\\', 0xDC00])).map_ok(Path.to_raw) == Ok(WindowsU16s([0xD800]))
1057+
expect Path.parent(Path.windows_u16s([0xD800])).map_ok(Path.to_raw) == Ok(WindowsU16s([]))
1058+
expect Path.parent(Path.windows("🐦\\é.roc")).map_ok(Path.to_raw) == Ok(WindowsU16s([0xD83D, 0xDC26]))
1059+
1060+
## Windows grammar is determined by the representation on every operating system.
1061+
expect Path.parent(Path.windows("")) == Err(NoParent)
1062+
expect Path.parent(Path.windows("\\")) == Err(NoParent)
1063+
expect Path.parent(Path.windows("src\\main.roc")) == Ok(Path.windows("src"))
1064+
expect Path.parent(Path.windows("src\\lib/main.roc\\.//")) == Ok(Path.windows("src\\lib"))
1065+
expect Path.parent(Path.windows(".\\main.roc")) == Ok(Path.windows("."))
1066+
expect Path.parent(Path.windows("C:")) == Err(NoParent)
1067+
expect Path.parent(Path.windows("C:\\")) == Err(NoParent)
1068+
expect Path.parent(Path.windows("C:/")) == Err(NoParent)
1069+
expect Path.parent(Path.windows("C:main.roc")) == Ok(Path.windows("C:"))
1070+
expect Path.parent(Path.windows("C:.\\main.roc")) == Ok(Path.windows("C:."))
1071+
expect Path.parent(Path.windows("C:.")) == Err(NoParent)
1072+
expect Path.parent(Path.windows("C:\\src")) == Ok(Path.windows("C:\\"))
1073+
expect Path.parent(Path.windows("c:/src")) == Ok(Path.windows("c:/"))
1074+
expect Path.parent(Path.windows("\\src")) == Ok(Path.windows("\\"))
1075+
expect Path.parent(Path.windows("\\\\server\\share")) == Err(NoParent)
1076+
expect Path.parent(Path.windows("\\\\server\\share\\")) == Err(NoParent)
1077+
expect Path.parent(Path.windows("\\\\server\\share\\src")) == Ok(Path.windows("\\\\server\\share\\"))
1078+
expect Path.parent(Path.windows("//server/share/src")) == Ok(Path.windows("//server/share/"))
1079+
expect Path.parent(Path.windows("\\\\server")) == Ok(Path.windows("\\"))
1080+
expect Path.parent(Path.windows("\\\\.\\pipe")) == Err(NoParent)
1081+
expect Path.parent(Path.windows("\\\\.\\pipe\\name")) == Ok(Path.windows("\\\\.\\pipe\\"))
1082+
expect Path.parent(Path.windows("//./pipe/name")) == Ok(Path.windows("//./pipe/"))
1083+
1084+
## Windows verbatim prefixes preserve dots and treat forward slashes as filename units.
1085+
expect Path.parent(Path.windows("\\\\?\\C:")) == Err(NoParent)
1086+
expect Path.parent(Path.windows("\\\\?\\C:\\")) == Err(NoParent)
1087+
expect Path.parent(Path.windows("\\\\?\\C:\\src")) == Ok(Path.windows("\\\\?\\C:\\"))
1088+
expect Path.parent(Path.windows("\\\\?\\C:\\src\\.")) == Ok(Path.windows("\\\\?\\C:\\src"))
1089+
expect Path.parent(Path.windows("\\\\?\\C:\\src\\.\\file")) == Ok(Path.windows("\\\\?\\C:\\src\\."))
1090+
expect Path.parent(Path.windows("\\\\?\\C:\\src/file")) == Ok(Path.windows("\\\\?\\C:\\"))
1091+
expect Path.parent(Path.windows("\\\\?\\C:/src/file")) == Ok(Path.windows("\\\\?\\C:/"))
1092+
expect Path.parent(Path.windows("\\\\?\\UNC\\server\\share")) == Err(NoParent)
1093+
expect Path.parent(Path.windows("\\\\?\\UNC\\server\\share\\src")) == Ok(Path.windows("\\\\?\\UNC\\server\\share\\"))
1094+
expect Path.parent(Path.windows("\\\\?\\UNC\\server")) == Err(NoParent)
1095+
expect Path.parent(Path.windows("\\\\?\\Volume{abc}\\src")) == Ok(Path.windows("\\\\?\\Volume{abc}\\"))
1096+
expect Path.parent(Path.windows("\\\\?\\Volume{abc}")) == Err(NoParent)
1097+
1098+
## Ancestors starts with the original path, includes an empty relative parent,
1099+
## and stops at roots and prefixes. It can also be consumed incrementally.
1100+
expect Path.ancestors("src/lib/main.roc").collect() == [Path.utf8("src/lib/main.roc"), Path.utf8("src/lib"), Path.utf8("src"), Path.utf8("")]
1101+
expect Path.ancestors("/src").collect() == [Path.utf8("/src"), Path.utf8("/")]
1102+
expect Path.ancestors("").collect() == [Path.utf8("")]
1103+
expect Path.ancestors("/").collect() == [Path.utf8("/")]
1104+
expect Path.ancestors("src///").collect() == [Path.utf8("src///"), Path.utf8("")]
1105+
expect Path.ancestors("src/../file").take_first(2).collect() == [Path.utf8("src/../file"), Path.utf8("src/..")]
1106+
expect Path.ancestors(Path.windows("C:\\src")).collect() == [Path.windows("C:\\src"), Path.windows("C:\\")]
1107+
expect Path.ancestors(Path.windows("\\\\server\\share\\src")).collect() == [Path.windows("\\\\server\\share\\src"), Path.windows("\\\\server\\share\\")]
1108+
expect Path.ancestors(Path.unix_bytes([0xFF, '/', 0xFE])).map(Path.to_raw).collect() == [UnixBytes([0xFF, '/', 0xFE]), UnixBytes([0xFF]), UnixBytes([])]
1109+
8531110
## Errors carry the path they were about, and successes pass through untouched.
8541111
expect {
8551112
result : Try({}, [PathErr(IOErr, Path)])

‎scripts/test_spec.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,8 @@
400400
{
401401
"name": "existing-source",
402402
"args": ["{source}"],
403-
"contains": ["Debug: Path.", "Filename: path.roc", "Extension: roc", "Type: IsFile"]
403+
"contains": ["Debug: Path.", "Filename: path.roc", "Extension: roc", "Type: IsFile"],
404+
"regex": ["^Parent: .*[/\\\\]examples$"]
404405
}
405406
]
406407
},

0 commit comments

Comments
 (0)