Skip to content

Commit ca2e83b

Browse files
dmisiukAnton-4
andauthored
Include the failing path in Path errors (#468)
* Include the failing path in Path errors * minor improvements --------- Co-authored-by: Anton-4 <17049058+Anton-4@users.noreply.github.com>
1 parent c351ad2 commit ca2e83b

5 files changed

Lines changed: 106 additions & 67 deletions

File tree

‎examples/error-handling.roc‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,23 @@ main! = |_args| {
1616
missing_file : Path
1717
missing_file = "nonexistent-file.txt"
1818

19-
# For professional software, use error tags (like `PathErr(NotFound)`) internally and convert
19+
# For professional software, use error tags (like `PathErr(NotFound, path)`) internally and convert
2020
# them to a message for the user at the edge of your program. This also makes it easy to provide
2121
# error messages in different languages.
2222
match missing_file.read_utf8!() {
2323
Ok(content) => Err(UnexpectedReadSuccess(content))?
24-
Err(PathErr(NotFound)) => Stderr.line!("Expected error: Path not found (NotFound)")?
25-
Err(PathErr(PermissionDenied)) => Stderr.line!("Error: Permission denied")?
26-
Err(PathErr(Other(msg))) => Stderr.line!("Error: ${msg}")?
27-
Err(_) => Stderr.line!("Error: Other file error")?
24+
# The failing path comes back with the error, so we can show it to the user.
25+
Err(PathErr(NotFound, path)) => Stderr.line!("(Expected ✅) error: Path not found (NotFound): ${path.display()}")?
26+
Err(PathErr(PermissionDenied, path)) => Stderr.line!("Unexpected Error: Permission denied for path:\n\t${path.display()}")?
27+
Err(PathErr(Other(msg), path)) => Stderr.line!("Unexpected Error:\n\t${msg}\nFor path:\n\t${path.display()}")?
28+
Err(err) => Stderr.line!("Unexpected Error: ${Str.inspect(err)}")?
2829
}
2930

3031
directory : Path
3132
directory = "examples"
3233

3334
match directory.read_bytes!() {
34-
Err(PathErr(IsADirectory)) => Stderr.line!("Expected error: Path is a directory (IsADirectory)")?
35+
Err(PathErr(IsADirectory, path)) => Stderr.line!("(Expected ✅) error: Path is a directory: ${path.display()}")?
3536
Ok(_) => Err(UnexpectedDirectoryReadSuccess)?
3637
Err(err) => Err(UnexpectedDirectoryReadError(err))?
3738
}
@@ -40,7 +41,7 @@ main! = |_args| {
4041
regular_file = "LICENSE"
4142
4243
match regular_file.list!() {
43-
Err(PathErr(NotADirectory)) => Stderr.line!("Expected error: Path is not a directory (NotADirectory)")?
44+
Err(PathErr(NotADirectory, path)) => Stderr.line!("(Expected ✅) error: Path is not a directory: ${path.display()}")?
4445
Ok(_) => Err(UnexpectedFileListSuccess)?
4546
Err(err) => Err(UnexpectedFileListError(err))?
4647
}

‎examples/file-accessed-modified-created-time.roc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ main! = |args| {
1818

1919
created_line = match file.time_created!() {
2020
Ok(time_created) => " Created: ${Utc.to_millis_since_epoch(time_created).to_str()} ms since epoch"
21-
Err(PathErr(Unsupported)) => " Created: unsupported"
21+
Err(PathErr(Unsupported, _path)) => " Created: unsupported"
2222
Err(err) => Err(err)?
2323
}
2424

‎platform/File.roc‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,21 @@ File :: [].{
2727

2828
## Open a file for buffered reading using the default buffer capacity.
2929
##
30+
## Failing to open reports the path, using the same `PathErr` as the
31+
## whole-file operations so both combine in one error type.
32+
##
3033
## ```roc
3134
## reader = File.open_reader!("LICENSE")?
3235
## line = reader.read_line!()?
3336
## ```
3437
open_reader! = |path|
3538
Host.file_open_reader!(Path.to_raw(path), 0)
3639
.map_ok(|reader| Reader.{ host: reader })
37-
.map_err(|FileErr(err)| FileErr(err))
40+
.map_err(|FileErr(err)| PathErr(err, path))
3841

3942
## Open a file for buffered reading using a specific buffer capacity.
4043
open_reader_with_capacity! = |path, capacity|
4144
Host.file_open_reader!(Path.to_raw(path), capacity)
4245
.map_ok(|reader| Reader.{ host: reader })
43-
.map_err(|FileErr(err)| FileErr(err))
46+
.map_err(|FileErr(err)| PathErr(err, path))
4447
}

‎platform/Path.roc‎

Lines changed: 90 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@ Path := [
3333
## This function does not traverse symbolic links; symbolic links (including
3434
## broken ones) return `Bool.False`.
3535
## Sockets, FIFOs, devices, and other special filesystem objects also return `Bool.False`.
36-
is_file! : Path => Try(Bool, [PathErr(IOErr), ..])
36+
is_file! : Path => Try(Bool, [PathErr(IOErr, Path), ..])
3737
is_file! = |path|
3838
match type!(path) {
3939
Ok(IsFile) => Ok(Bool.True)
4040
Ok(_) => Ok(Bool.False)
41-
Err(PathErr(NotFound)) => Ok(Bool.False)
41+
Err(PathErr(NotFound, _)) => Ok(Bool.False)
4242
Err(err) => Err(err)
4343
}
4444

@@ -47,12 +47,12 @@ Path := [
4747
## This function does not traverse symbolic links; symbolic links (including
4848
## broken ones) return `Bool.False`.
4949
## Sockets, FIFOs, devices, and other special filesystem objects also return `Bool.False`.
50-
is_dir! : Path => Try(Bool, [PathErr(IOErr), ..])
50+
is_dir! : Path => Try(Bool, [PathErr(IOErr, Path), ..])
5151
is_dir! = |path|
5252
match type!(path) {
5353
Ok(IsDir) => Ok(Bool.True)
5454
Ok(_) => Ok(Bool.False)
55-
Err(PathErr(NotFound)) => Ok(Bool.False)
55+
Err(PathErr(NotFound, _)) => Ok(Bool.False)
5656
Err(err) => Err(err)
5757
}
5858

@@ -61,21 +61,21 @@ Path := [
6161
## This function will not traverse symbolic links - it checks whether the path
6262
## itself is a symlink.
6363
## Sockets, FIFOs, devices, and other special filesystem objects return `Bool.False`.
64-
is_sym_link! : Path => Try(Bool, [PathErr(IOErr), ..])
64+
is_sym_link! : Path => Try(Bool, [PathErr(IOErr, Path), ..])
6565
is_sym_link! = |path|
6666
match type!(path) {
6767
Ok(IsSymLink) => Ok(Bool.True)
6868
Ok(_) => Ok(Bool.False)
69-
Err(PathErr(NotFound)) => Ok(Bool.False)
69+
Err(PathErr(NotFound, _)) => Ok(Bool.False)
7070
Err(err) => Err(err)
7171
}
7272

7373
## Returns `True` if the path exists on disk.
74-
exists! : Path => Try(Bool, [PathErr(IOErr), ..])
74+
exists! : Path => Try(Bool, [PathErr(IOErr, Path), ..])
7575
exists! = |path|
7676
match type!(path) {
7777
Ok(_) => Ok(Bool.True)
78-
Err(PathErr(NotFound)) => Ok(Bool.False)
78+
Err(PathErr(NotFound, _)) => Ok(Bool.False)
7979
Err(err) => Err(err)
8080
}
8181

@@ -84,105 +84,111 @@ Path := [
8484
## `IsOther` represents sockets, FIFOs, block and character devices, and any
8585
## platform-specific object that is not a regular file, directory, or symbolic
8686
## link. On Windows this includes unrecognized reparse-point types.
87-
type! : Path => Try([IsFile, IsDir, IsSymLink, IsOther], [PathErr(IOErr), ..])
87+
type! : Path => Try([IsFile, IsDir, IsSymLink, IsOther], [PathErr(IOErr, Path), ..])
8888
type! = |path| {
8989
Host.path_type!(to_raw(path))
90-
.map_err(|err| PathErr(err))
90+
.map_err(|err| PathErr(err, path))
9191
.map_ok(path_type_from_host)
9292
}
9393

9494
## Read all bytes from a file at this path.
95-
read_bytes! : Path => Try(List(U8), [PathErr(IOErr), ..])
96-
read_bytes! = |path| map_file_result(Host.file_read_bytes!(to_raw(path)))
95+
read_bytes! : Path => Try(List(U8), [PathErr(IOErr, Path), ..])
96+
read_bytes! = |path| map_file_result(Host.file_read_bytes!(to_raw(path)), path)
9797

9898
## Write bytes to a file at this path, replacing any existing contents.
99-
write_bytes! : Path, List(U8) => Try({}, [PathErr(IOErr), ..])
100-
write_bytes! = |path, bytes| map_file_result(Host.file_write_bytes!(to_raw(path), bytes))
99+
write_bytes! : Path, List(U8) => Try({}, [PathErr(IOErr, Path), ..])
100+
write_bytes! = |path, bytes| map_file_result(Host.file_write_bytes!(to_raw(path), bytes), path)
101101

102102
## Read a UTF-8 file at this path.
103-
read_utf8! : Path => Try(Str, [PathErr(IOErr), ..])
104-
read_utf8! = |path| map_file_result(Host.file_read_utf8!(to_raw(path)))
103+
read_utf8! : Path => Try(Str, [PathErr(IOErr, Path), ..])
104+
read_utf8! = |path| map_file_result(Host.file_read_utf8!(to_raw(path)), path)
105105

106106
## Write a UTF-8 file at this path, replacing any existing contents.
107-
write_utf8! : Path, Str => Try({}, [PathErr(IOErr), ..])
108-
write_utf8! = |path, content| map_file_result(Host.file_write_utf8!(to_raw(path), content))
107+
write_utf8! : Path, Str => Try({}, [PathErr(IOErr, Path), ..])
108+
write_utf8! = |path, content| map_file_result(Host.file_write_utf8!(to_raw(path), content), path)
109109

110110
## Replace every occurrence of `pattern` with `replacement` in the UTF-8 file
111111
## at this path.
112112
##
113113
## This reads the whole file, substitutes, and writes it back, so it is not
114114
## atomic: a failure mid-write can leave the file partially written, exactly
115115
## as a bare [write_utf8!] would.
116-
replace_utf8! : Path, Str, Str => Try({}, [PathErr(IOErr), ..])
116+
replace_utf8! : Path, Str, Str => Try({}, [PathErr(IOErr, Path), ..])
117117
replace_utf8! = |path, pattern, replacement| {
118118
content = read_utf8!(path)?
119119
write_utf8!(path, Str.replace_each(content, pattern, replacement))
120120
}
121121

122122
## Delete a file at this path.
123-
delete! : Path => Try({}, [PathErr(IOErr), ..])
124-
delete! = |path| map_file_result(Host.file_delete!(to_raw(path)))
123+
delete! : Path => Try({}, [PathErr(IOErr, Path), ..])
124+
delete! = |path| map_file_result(Host.file_delete!(to_raw(path)), path)
125125

126126
## Return the size of the file at this path in bytes.
127-
size_in_bytes! : Path => Try(U64, [PathErr(IOErr), ..])
128-
size_in_bytes! = |path| map_file_result(Host.file_size_in_bytes!(to_raw(path)))
127+
size_in_bytes! : Path => Try(U64, [PathErr(IOErr, Path), ..])
128+
size_in_bytes! = |path| map_file_result(Host.file_size_in_bytes!(to_raw(path)), path)
129129

130130
## Check whether the file at this path has any executable bit set.
131-
is_executable! : Path => Try(Bool, [PathErr(IOErr), ..])
132-
is_executable! = |path| map_file_result(Host.file_is_executable!(to_raw(path)))
131+
is_executable! : Path => Try(Bool, [PathErr(IOErr, Path), ..])
132+
is_executable! = |path| map_file_result(Host.file_is_executable!(to_raw(path)), path)
133133

134134
## Check whether the file at this path has a readable owner permission bit set.
135-
is_readable! : Path => Try(Bool, [PathErr(IOErr), ..])
136-
is_readable! = |path| map_file_result(Host.file_is_readable!(to_raw(path)))
135+
is_readable! : Path => Try(Bool, [PathErr(IOErr, Path), ..])
136+
is_readable! = |path| map_file_result(Host.file_is_readable!(to_raw(path)), path)
137137

138138
## Check whether the file at this path has a writable owner permission bit set.
139-
is_writable! : Path => Try(Bool, [PathErr(IOErr), ..])
140-
is_writable! = |path| map_file_result(Host.file_is_writable!(to_raw(path)))
139+
is_writable! : Path => Try(Bool, [PathErr(IOErr, Path), ..])
140+
is_writable! = |path| map_file_result(Host.file_is_writable!(to_raw(path)), path)
141141

142142
## Return the last accessed time as nanoseconds since the Unix epoch.
143-
time_accessed! : Path => Try(U128, [PathErr(IOErr), ..])
144-
time_accessed! = |path| map_file_result(Host.file_time_accessed!(to_raw(path)))
143+
time_accessed! : Path => Try(U128, [PathErr(IOErr, Path), ..])
144+
time_accessed! = |path| map_file_result(Host.file_time_accessed!(to_raw(path)), path)
145145

146146
## Return the last modified time as nanoseconds since the Unix epoch.
147-
time_modified! : Path => Try(U128, [PathErr(IOErr), ..])
148-
time_modified! = |path| map_file_result(Host.file_time_modified!(to_raw(path)))
147+
time_modified! : Path => Try(U128, [PathErr(IOErr, Path), ..])
148+
time_modified! = |path| map_file_result(Host.file_time_modified!(to_raw(path)), path)
149149

150150
## Return the creation time as nanoseconds since the Unix epoch.
151-
time_created! : Path => Try(U128, [PathErr(IOErr), ..])
152-
time_created! = |path| map_file_result(Host.file_time_created!(to_raw(path)))
151+
time_created! : Path => Try(U128, [PathErr(IOErr, Path), ..])
152+
time_created! = |path| map_file_result(Host.file_time_created!(to_raw(path)), path)
153153

154154
## Create a hard link at `link` pointing to `original`.
155-
hard_link! : Path, Path => Try({}, [PathErr(IOErr), ..])
155+
##
156+
## Errors report `original`, which is what a missing-file failure refers to.
157+
## Some failures concern `link` instead, such as `AlreadyExists`.
158+
hard_link! : Path, Path => Try({}, [PathErr(IOErr, Path), ..])
156159
hard_link! = |original, link|
157-
map_file_result(Host.file_hard_link!(to_raw(original), to_raw(link)))
160+
map_file_result(Host.file_hard_link!(to_raw(original), to_raw(link)), original)
158161

159162
## Rename a file from `from` to `to`.
160-
rename! : Path, Path => Try({}, [PathErr(IOErr), ..])
163+
##
164+
## Errors report `from`; a failure may still concern `to`, such as its
165+
## parent directory not existing.
166+
rename! : Path, Path => Try({}, [PathErr(IOErr, Path), ..])
161167
rename! = |from, to|
162-
map_file_result(Host.file_rename!(to_raw(from), to_raw(to)))
168+
map_file_result(Host.file_rename!(to_raw(from), to_raw(to)), from)
163169

164170
## Create a directory at this path.
165-
create_dir! : Path => Try({}, [PathErr(IOErr), ..])
166-
create_dir! = |path| map_dir_result(Host.dir_create!(to_raw(path)))
171+
create_dir! : Path => Try({}, [PathErr(IOErr, Path), ..])
172+
create_dir! = |path| map_dir_result(Host.dir_create!(to_raw(path)), path)
167173

168174
## Create a directory and any missing parent directories at this path.
169-
create_all! : Path => Try({}, [PathErr(IOErr), ..])
170-
create_all! = |path| map_dir_result(Host.dir_create_all!(to_raw(path)))
175+
create_all! : Path => Try({}, [PathErr(IOErr, Path), ..])
176+
create_all! = |path| map_dir_result(Host.dir_create_all!(to_raw(path)), path)
171177

172178
## Delete an empty directory at this path.
173-
delete_empty! : Path => Try({}, [PathErr(IOErr), ..])
174-
delete_empty! = |path| map_dir_result(Host.dir_delete_empty!(to_raw(path)))
179+
delete_empty! : Path => Try({}, [PathErr(IOErr, Path), ..])
180+
delete_empty! = |path| map_dir_result(Host.dir_delete_empty!(to_raw(path)), path)
175181

176182
## Delete a directory and all contents at this path.
177-
delete_all! : Path => Try({}, [PathErr(IOErr), ..])
178-
delete_all! = |path| map_dir_result(Host.dir_delete_all!(to_raw(path)))
183+
delete_all! : Path => Try({}, [PathErr(IOErr, Path), ..])
184+
delete_all! = |path| map_dir_result(Host.dir_delete_all!(to_raw(path)), path)
179185

180186
## List the entries in the directory at this path.
181-
list! : Path => Try(List(Path), [PathErr(IOErr), ..])
187+
list! : Path => Try(List(Path), [PathErr(IOErr, Path), ..])
182188
list! = |path|
183189
match Host.dir_list!(to_raw(path)) {
184190
Ok(paths) => Ok(paths.map(from_raw))
185-
Err(DirErr(err)) => Err(PathErr(err))
191+
Err(DirErr(err)) => Err(PathErr(err, path))
186192
}
187193

188194
## Create a UTF-8 text path.
@@ -356,18 +362,18 @@ Path := [
356362
}
357363
}
358364

359-
map_file_result : Try(a, [FileErr(IOErr)]) -> Try(a, [PathErr(IOErr), ..])
360-
map_file_result = |result|
365+
map_file_result : Try(a, [FileErr(IOErr)]), Path -> Try(a, [PathErr(IOErr, Path), ..])
366+
map_file_result = |result, path|
361367
match result {
362368
Ok(value) => Ok(value)
363-
Err(FileErr(err)) => Err(PathErr(err))
369+
Err(FileErr(err)) => Err(PathErr(err, path))
364370
}
365371

366-
map_dir_result : Try(a, [DirErr(IOErr)]) -> Try(a, [PathErr(IOErr), ..])
367-
map_dir_result = |result|
372+
map_dir_result : Try(a, [DirErr(IOErr)]), Path -> Try(a, [PathErr(IOErr, Path), ..])
373+
map_dir_result = |result, path|
368374
match result {
369375
Ok(value) => Ok(value)
370-
Err(DirErr(err)) => Err(PathErr(err))
376+
Err(DirErr(err)) => Err(PathErr(err, path))
371377
}
372378

373379
str_from_valid_utf8 : List(U8) -> Str
@@ -696,3 +702,32 @@ expect path_type_from_host(Other) == IsOther
696702
expect Path.join(Path.unix("foo"), "bar") == Path.unix("foo/bar")
697703
expect Path.join(Path.windows("foo"), "bar") == Path.windows("foo\\bar")
698704
expect Path.join(Path.utf8("foo"), "bar") == Path.utf8("foo/bar")
705+
706+
## Errors carry the path they were about, and successes pass through untouched.
707+
expect {
708+
result : Try({}, [PathErr(IOErr, Path)])
709+
result = map_file_result(Err(FileErr(NotFound)), Path.utf8("a.txt"))
710+
match result {
711+
Err(PathErr(NotFound, path)) => path == Path.utf8("a.txt")
712+
_ => Bool.False
713+
}
714+
}
715+
716+
expect {
717+
result : Try({}, [PathErr(IOErr, Path)])
718+
result = map_dir_result(Err(DirErr(NotADirectory)), Path.unix_bytes([0x2F, 0xFF]))
719+
match result {
720+
# Raw non-UTF-8 bytes survive the trip through the error.
721+
Err(PathErr(NotADirectory, path)) => path == Path.unix_bytes([0x2F, 0xFF])
722+
_ => Bool.False
723+
}
724+
}
725+
726+
expect {
727+
result : Try(U64, [PathErr(IOErr, Path)])
728+
result = map_file_result(Ok(42), Path.utf8("a.txt"))
729+
match result {
730+
Ok(value) => value == 42
731+
Err(_) => Bool.False
732+
}
733+
}

‎scripts/test_spec.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
{
9090
"name": "handled-not-found",
9191
"contains": [
92-
"Expected error: Path not found (NotFound)",
92+
"Expected error: Path not found (NotFound): nonexistent-file.txt",
9393
"Expected error: Path is a directory (IsADirectory)",
9494
"Expected error: Path is not a directory (NotADirectory)",
9595
"test-file.txt contains: Hello from error-handling example!"
@@ -173,7 +173,7 @@
173173
"args": ["missing.txt"],
174174
"temp_cwd": true,
175175
"exit_code": 1,
176-
"contains": ["PathErr(NotFound)"]
176+
"contains": ["PathErr(NotFound, Path."]
177177
}
178178
]
179179
},

0 commit comments

Comments
 (0)