diff --git a/examples/interactive/.gitignore b/examples/interactive/.gitignore index cb41e998d8..6eacf36982 100644 --- a/examples/interactive/.gitignore +++ b/examples/interactive/.gitignore @@ -4,3 +4,4 @@ effects form tui http-get +file-io diff --git a/examples/interactive/cli-platform/Dir.roc b/examples/interactive/cli-platform/Dir.roc new file mode 100644 index 0000000000..6b657600b3 --- /dev/null +++ b/examples/interactive/cli-platform/Dir.roc @@ -0,0 +1,25 @@ +interface Dir + exposes [ReadErr, DeleteErr, DirEntry, deleteEmptyDir, deleteRecursive, list] + imports [Effect, Task.{ Task }, InternalTask, Path.{ Path }, InternalPath, InternalDir] + +ReadErr : InternalDir.ReadErr + +DeleteErr : InternalDir.DeleteErr + +DirEntry : InternalDir.DirEntry + +## Lists the files and directories inside the directory. +list : Path -> Task (List Path) [DirReadErr Path ReadErr]* [Read [File]*]* +list = \path -> + effect = Effect.map (Effect.dirList (InternalPath.toBytes path)) \result -> + when result is + Ok entries -> Ok (List.map entries InternalPath.fromOsBytes) + Err err -> Err (DirReadErr path err) + + InternalTask.fromEffect effect + +## Deletes a directory if it's empty. +deleteEmptyDir : Path -> Task {} [DirDeleteErr Path DeleteErr]* [Write [File]*]* + +## Recursively deletes the directory as well as all files and directories inside it. +deleteRecursive : Path -> Task {} [DirDeleteErr Path DeleteErr]* [Write [File]*]* diff --git a/examples/interactive/cli-platform/Effect.roc b/examples/interactive/cli-platform/Effect.roc index 634bc7eeeb..26505ddc07 100644 --- a/examples/interactive/cli-platform/Effect.roc +++ b/examples/interactive/cli-platform/Effect.roc @@ -6,15 +6,18 @@ hosted Effect always, forever, loop, + dirList, + cwd, stdoutLine, stderrLine, stdinLine, sendRequest, fileReadBytes, + fileDelete, fileWriteUtf8, fileWriteBytes, ] - imports [InternalHttp.{ Request, Response }, InternalFile] + imports [InternalHttp.{ Request, Response }, InternalFile, InternalDir] generates Effect with [after, map, always, forever, loop] stdoutLine : Str -> Effect {} @@ -23,6 +26,10 @@ stdinLine : Effect Str fileWriteBytes : List U8, List U8 -> Effect (Result {} InternalFile.WriteErr) fileWriteUtf8 : List U8, Str -> Effect (Result {} InternalFile.WriteErr) +fileDelete : List U8 -> Effect (Result {} InternalFile.WriteErr) fileReadBytes : List U8 -> Effect (Result (List U8) InternalFile.ReadErr) +dirList : List U8 -> Effect (Result (List (List U8)) InternalDir.ReadErr) + +cwd : Effect (List U8) sendRequest : Box Request -> Effect Response diff --git a/examples/interactive/cli-platform/Env.roc b/examples/interactive/cli-platform/Env.roc new file mode 100644 index 0000000000..72a10e8d61 --- /dev/null +++ b/examples/interactive/cli-platform/Env.roc @@ -0,0 +1,15 @@ +interface Env + exposes [cwd] + imports [Task.{ Task }, Path.{ Path }, InternalPath, Effect, InternalTask] + +## Reads the [current working directory](https://en.wikipedia.org/wiki/Working_directory) +## from the environment. +cwd : Task Path [CwdUnavailable]* [Env]* +cwd = + effect = Effect.map Effect.cwd \bytes -> + if List.isEmpty bytes then + Err CwdUnavailable + else + Ok (InternalPath.fromArbitraryBytes bytes) + + InternalTask.fromEffect effect diff --git a/examples/interactive/cli-platform/File.roc b/examples/interactive/cli-platform/File.roc index f6e84107ab..de0181ecce 100644 --- a/examples/interactive/cli-platform/File.roc +++ b/examples/interactive/cli-platform/File.roc @@ -1,11 +1,13 @@ interface File - exposes [ReadErr, WriteErr, write, writeUtf8, writeBytes, readUtf8, readBytes] - imports [Effect, Task.{ Task }, InternalTask, InternalFile, Path.{ Path }, InternalPath] + exposes [ReadErr, WriteErr, write, writeUtf8, writeBytes, readUtf8, readBytes, delete] + imports [Task.{ Task }, InternalTask, InternalFile, Path.{ Path }, InternalPath, Effect.{ Effect }] ReadErr : InternalFile.ReadErr WriteErr : InternalFile.WriteErr +## Encodes a value using the given `EncodingFormat` and writes it to a file. +## ## For example, suppose you have a [JSON](https://en.wikipedia.org/wiki/JSON) ## `EncodingFormat` named `Json.toCompactUtf8`. Then you can use that format ## to write some encodable data to a file as JSON, like so: @@ -31,7 +33,7 @@ write = \path, val, fmt -> # TODO handle encoding errors here, once they exist writeBytes path bytes -## Write bytes to a file. +## Writes bytes to a file. ## ## # Writes the bytes 1, 2, 3 to the file `myfile.dat`. ## File.writeBytes (Path.fromStr "myfile.dat") [1, 2, 3] @@ -41,12 +43,9 @@ write = \path, val, fmt -> ## To format data before writing it to a file, you can use [File.write] instead. writeBytes : Path, List U8 -> Task {} [FileWriteErr Path WriteErr]* [Write [File]*]* writeBytes = \path, bytes -> - InternalPath.toBytes path - |> Effect.fileWriteBytes bytes - |> InternalTask.fromEffect - |> Task.mapFail \err -> FileWriteErr path err + toWriteTask path \pathBytes -> Effect.fileWriteBytes pathBytes bytes -## Write a [Str] to a file, encoded as [UTF-8](https://en.wikipedia.org/wiki/UTF-8). +## Writes a [Str] to a file, encoded as [UTF-8](https://en.wikipedia.org/wiki/UTF-8). ## ## # Writes "Hello!" encoded as UTF-8 to the file `myfile.txt`. ## File.writeUtf8 (Path.fromStr "myfile.txt") "Hello!" @@ -56,12 +55,29 @@ writeBytes = \path, bytes -> ## To write unformatted bytes to a file, you can use [File.writeBytes] instead. writeUtf8 : Path, Str -> Task {} [FileWriteErr Path WriteErr]* [Write [File]*]* writeUtf8 = \path, str -> - InternalPath.toBytes path - |> Effect.fileWriteUtf8 str - |> InternalTask.fromEffect - |> Task.mapFail \err -> FileWriteErr path err + toWriteTask path \bytes -> Effect.fileWriteUtf8 bytes str -## Read all the bytes in a file. +## Deletes a file from the filesystem. +## +## # Deletes the file named +## File.delete (Path.fromStr "myfile.dat") [1, 2, 3] +## +## Note that this does not securely erase the file's contents from disk; instead, the operating +## system marks the space it was occupying as safe to write over in the future. Also, the operating +## system may not immediately mark the space as free; for example, on Windows it will wait until +## the last file handle to it is closed, and on UNIX, it will not remove it until the last +## [hard link](https://en.wikipedia.org/wiki/Hard_link) to it has been deleted. +## +## This performs a [`DeleteFile`](https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-deletefile) +## on Windows and [`unlink`](https://en.wikipedia.org/wiki/Unlink_(Unix)) on UNIX systems. +## +## On Windows, this will fail when attempting to delete a readonly file; the file's +## readonly permission must be disabled before it can be successfully deleted. +delete : Path -> Task {} [FileWriteErr Path WriteErr]* [Write [File]*]* +delete = \path -> + toWriteTask path \bytes -> Effect.fileDelete bytes + +## Reads all the bytes in a file. ## ## # Read all the bytes in `myfile.txt`. ## File.readBytes (Path.fromStr "myfile.txt") @@ -71,12 +87,9 @@ writeUtf8 = \path, str -> ## To read and decode data from a file, you can use `File.read` instead. readBytes : Path -> Task (List U8) [FileReadErr Path ReadErr]* [Read [File]*]* readBytes = \path -> - InternalPath.toBytes path - |> Effect.fileReadBytes - |> InternalTask.fromEffect - |> Task.mapFail \err -> FileReadErr path err + toReadTask path \bytes -> Effect.fileReadBytes bytes -## Read a [Str] from a file containing [UTF-8](https://en.wikipedia.org/wiki/UTF-8)-encoded text. +## Reads a [Str] from a file containing [UTF-8](https://en.wikipedia.org/wiki/UTF-8)-encoded text. ## ## # Reads UTF-8 encoded text into a `Str` from the file `myfile.txt`. ## File.readUtf8 (Path.fromStr "myfile.txt") @@ -119,3 +132,16 @@ readUtf8 = \path -> # Err decodingErr -> Err (FileReadDecodeErr decodingErr) # Err readErr -> Err (FileReadErr readErr) # InternalTask.fromEffect effect +toWriteTask : Path, (List U8 -> Effect (Result ok err)) -> Task ok [FileWriteErr Path err]* [Write [File]*]* +toWriteTask = \path, toEffect -> + InternalPath.toBytes path + |> toEffect + |> InternalTask.fromEffect + |> Task.mapFail \err -> FileWriteErr path err + +toReadTask : Path, (List U8 -> Effect (Result ok err)) -> Task ok [FileReadErr Path err]* [Read [File]*]* +toReadTask = \path, toEffect -> + InternalPath.toBytes path + |> toEffect + |> InternalTask.fromEffect + |> Task.mapFail \err -> FileReadErr path err diff --git a/examples/interactive/cli-platform/FileMetadata.roc b/examples/interactive/cli-platform/FileMetadata.roc new file mode 100644 index 0000000000..4c071e6fa4 --- /dev/null +++ b/examples/interactive/cli-platform/FileMetadata.roc @@ -0,0 +1,32 @@ +interface FileMetadata + exposes [FileMetadata, bytes, type, isReadonly, mode] + imports [] + +# Design note: this is an opaque type rather than a type alias so that +# we can add new operating system info if new OS releases introduce them, +# as a backwards-compatible change. +FileMetadata := { + bytes : U64, + type : [File, Dir, Symlink], + isReadonly : Bool, + mode : [Unix U32, NonUnix], +} + +bytes : FileMetadata -> U64 +bytes = \@FileMetadata info -> info.bytes + +isReadonly : FileMetadata -> Bool +isReadonly = \@FileMetadata info -> info.isReadonly + +type : FileMetadata -> [File, Dir, Symlink] +type = \@FileMetadata info -> info.type + +mode : FileMetadata -> [Unix U32, NonUnix] +mode = \@FileMetadata info -> info.mode + +# TODO need to create a Time module and return something like Time.Utc here. +# lastModified : FileMetadata -> Utc +# TODO need to create a Time module and return something like Time.Utc here. +# lastAccessed : FileMetadata -> Utc +# TODO need to create a Time module and return something like Time.Utc here. +# created : FileMetadata -> Utc diff --git a/examples/interactive/cli-platform/InternalDir.roc b/examples/interactive/cli-platform/InternalDir.roc new file mode 100644 index 0000000000..6163125e84 --- /dev/null +++ b/examples/interactive/cli-platform/InternalDir.roc @@ -0,0 +1,41 @@ +interface InternalDir + exposes [ReadErr, DeleteErr, DirEntry] + imports [FileMetadata.{ FileMetadata }, Path.{ Path }] + +DirEntry : { + path : Path, + type : [File, Dir, Symlink], + metadata : FileMetadata, +} + +ReadErr : [ + NotFound, + Interrupted, + InvalidFilename, + PermissionDenied, + TooManySymlinks, # aka FilesystemLoop + TooManyHardlinks, + TimedOut, + StaleNetworkFileHandle, + NotADirectory, + OutOfMemory, + Unsupported, + Unrecognized I32 Str, +] + +DeleteErr : [ + NotFound, + Interrupted, + InvalidFilename, + PermissionDenied, + TooManySymlinks, # aka FilesystemLoop + TooManyHardlinks, + TimedOut, + StaleNetworkFileHandle, + NotADirectory, + ReadOnlyFilesystem, + DirectoryNotEmpty, + OutOfMemory, + Unsupported, + Unrecognized I32 Str, +] diff --git a/examples/interactive/cli-platform/InternalPath.roc b/examples/interactive/cli-platform/InternalPath.roc index b27ff8e5e5..10ebe7f0e5 100644 --- a/examples/interactive/cli-platform/InternalPath.roc +++ b/examples/interactive/cli-platform/InternalPath.roc @@ -5,6 +5,8 @@ interface InternalPath wrap, unwrap, toBytes, + fromArbitraryBytes, + fromOsBytes, ] imports [] @@ -61,3 +63,11 @@ toBytes = \@InternalPath path -> FromOperatingSystem bytes -> bytes ArbitraryBytes bytes -> bytes FromStr str -> Str.toUtf8 str + +fromArbitraryBytes : List U8 -> InternalPath +fromArbitraryBytes = \bytes -> + @InternalPath (ArbitraryBytes bytes) + +fromOsBytes : List U8 -> InternalPath +fromOsBytes = \bytes -> + @InternalPath (FromOperatingSystem bytes) diff --git a/examples/interactive/cli-platform/Path.roc b/examples/interactive/cli-platform/Path.roc index 12de18ee32..7a9453df5e 100644 --- a/examples/interactive/cli-platform/Path.roc +++ b/examples/interactive/cli-platform/Path.roc @@ -6,6 +6,7 @@ interface Path WindowsRoot, # toComponents, # walkComponents, + display, fromStr, fromBytes, withExtension, @@ -82,13 +83,13 @@ fromBytes = \bytes -> ## have been encoded with the same charset as the operating system's curent locale (which ## typically does not change after it is set during installation of the OS), so ## this should convert a [Path] to a valid string as long as the path was created -## with the given [Charset]. (Use [Env.charset] to get the current system charset.) +## with the given `Charset`. (Use `Env.charset` to get the current system charset.) ## ## For a conversion to [Str] that is lossy but does not return a [Result], see -## [displayUtf8]. +## [display]. # toInner : Path -> [Str Str, Bytes (List U8)] ## Assumes a path is encoded as [UTF-8](https://en.wikipedia.org/wiki/UTF-8), -## and converts it to a string using [Str.displayUtf8]. +## and converts it to a string using `Str.display`. ## ## This conversion is lossy because the path may contain invalid UTF-8 bytes. If that happens, ## any invalid bytes will be replaced with the [Unicode replacement character](https://unicode.org/glossary/#replacement_character) @@ -103,17 +104,21 @@ fromBytes = \bytes -> ## Converting paths to strings can be an unreliable operation, because operating systems ## don't record the paths' encodings. This means it's possible for the path to have been ## encoded with a different character set than UTF-8 even if UTF-8 is the system default, -## which means when [displayUtf8] converts them to a string, the string may include gibberish. +## which means when [display] converts them to a string, the string may include gibberish. ## [Here is an example.](https://unix.stackexchange.com/questions/667652/can-a-file-path-be-invalid-utf-8/667863#667863) ## -## If you happen to know the [Charset] that was used to encode the path, you can use -## [toStrUsingCharset] instead of [displayUtf8]. -# displayUtf8 : Path -> Str -# displayUtf8 = \path -> -# when InternalPath.unwrap path is -# FromStr str -> str -# FromOperatingSystem bytes | ArbitraryBytes bytes -> -# Str.displayUtf8 bytes +## If you happen to know the `Charset` that was used to encode the path, you can use +## `toStrUsingCharset` instead of [display]. +display : Path -> Str +display = \path -> + when InternalPath.unwrap path is + FromStr str -> str + FromOperatingSystem bytes | ArbitraryBytes bytes -> + when Str.fromUtf8 bytes is + Ok str -> str + # TODO: this should use the builtin Str.display to display invalid UTF-8 chars in just the right spots, but that does not exist yet! + Err _ -> "�" + # isEq : Path, Path -> Bool # isEq = \p1, p2 -> # when InternalPath.unwrap p1 is diff --git a/examples/interactive/cli-platform/src/lib.rs b/examples/interactive/cli-platform/src/lib.rs index cf7f4337e1..d801fcd8da 100644 --- a/examples/interactive/cli-platform/src/lib.rs +++ b/examples/interactive/cli-platform/src/lib.rs @@ -210,11 +210,76 @@ pub fn os_str_from_list(bytes: &RocList) -> &OsStr { } #[no_mangle] -pub extern "C" fn roc_fx_fileReadBytes(path: &RocList) -> RocResult, ReadErr> { - let path = path_from_roc_path(path); - println!("TODO read bytes from {:?}", path); +pub extern "C" fn roc_fx_fileReadBytes(roc_path: &RocList) -> RocResult, ReadErr> { + use std::io::Read; - RocResult::ok(RocList::empty()) + let mut bytes = Vec::new(); + + match File::open(path_from_roc_path(roc_path)) { + Ok(mut file) => match file.read_to_end(&mut bytes) { + Ok(_bytes_read) => RocResult::ok(RocList::from(bytes.as_slice())), + Err(_) => { + todo!("Report a file write error"); + } + }, + Err(_) => { + todo!("Report a file open error"); + } + } +} + +#[no_mangle] +pub extern "C" fn roc_fx_fileDelete(roc_path: &RocList) -> RocResult<(), ReadErr> { + match std::fs::remove_file(path_from_roc_path(roc_path)) { + Ok(()) => RocResult::ok(()), + Err(_) => { + todo!("Report a file write error"); + } + } +} + +#[no_mangle] +pub extern "C" fn roc_fx_cwd() -> RocList { + // TODO instead, call getcwd on UNIX and GetCurrentDirectory on Windows + match std::env::current_dir() { + Ok(path_buf) => os_str_to_roc_path(path_buf.into_os_string().as_os_str()), + Err(_) => { + // Default to empty path + RocList::empty() + } + } +} + +#[no_mangle] +pub extern "C" fn roc_fx_dirList( + // TODO: this RocResult should use Dir.WriteErr - but right now it's File.WriteErr + // because glue doesn't have Dir.WriteErr yet. + roc_path: &RocList, +) -> RocResult>, WriteErr> { + println!("Dir.list..."); + match std::fs::read_dir(path_from_roc_path(roc_path)) { + Ok(dir_entries) => RocResult::ok( + dir_entries + .map(|opt_dir_entry| match opt_dir_entry { + Ok(entry) => os_str_to_roc_path(entry.path().into_os_string().as_os_str()), + Err(_) => { + todo!("handle dir_entry path didn't resolve") + } + }) + .collect::>>(), + ), + Err(_) => { + todo!("handle Dir.list error"); + } + } +} + +#[cfg(target_family = "unix")] +/// TODO convert from EncodeWide to RocPath on Windows +fn os_str_to_roc_path(os_str: &OsStr) -> RocList { + use std::os::unix::ffi::OsStrExt; + + RocList::from(os_str.as_bytes()) } #[no_mangle] diff --git a/examples/interactive/file.roc b/examples/interactive/file.roc index 4f30de7065..c022f2e1f9 100644 --- a/examples/interactive/file.roc +++ b/examples/interactive/file.roc @@ -1,17 +1,30 @@ -app "file" +app "file-io" packages { pf: "cli-platform/main.roc" } - imports [pf.Stdout, pf.Stderr, pf.Task, pf.File, pf.Path] + imports [pf.Stdout, pf.Stderr, pf.Task, pf.File, pf.Path, pf.Env, pf.Dir] provides [main] to pf -main : Task.Task {} [] [Write [File, Stdout, Stderr]] +main : Task.Task {} [] [Write [File, Stdout, Stderr], Read [File], Env] main = + path = Path.fromStr "out.txt" task = + cwd <- Env.cwd |> Task.await + cwdStr = Path.display cwd + + _ <- Stdout.line "cwd: \(cwdStr)" |> Task.await + dirEntries <- Dir.list cwd |> Task.await + contentsStr = Str.joinWith (List.map dirEntries Path.display) "\n " + + _ <- Stdout.line "Directory contents:\n \(contentsStr)\n" |> Task.await _ <- Stdout.line "Writing a string to out.txt" |> Task.await - File.writeUtf8 (Path.fromStr "out.txt") "a string!\n" + _ <- File.writeUtf8 path "a string!" |> Task.await + contents <- File.readUtf8 path |> Task.await + Stdout.line "I read the file back. Its contents: \"\(contents)\"" Task.attempt task \result -> when result is Err (FileWriteErr _ PermissionDenied) -> Stderr.line "Err: PermissionDenied" Err (FileWriteErr _ Unsupported) -> Stderr.line "Err: Unsupported" Err (FileWriteErr _ (Unrecognized _ other)) -> Stderr.line "Err: \(other)" - _ -> Stdout.line "Successfully wrote a string to out.txt" + Err (FileReadErr _ _) -> Stderr.line "Error reading file" + Err _ -> Stderr.line "Uh oh, there was an error!" + Ok _ -> Stdout.line "Successfully wrote a string to out.txt"