std.fs
std.fs is files, directories, and paths, added in 1.4.1 alongside the release that let a C struct cross the foreign boundary by value. Most of it is a thin skin over libc: a foreign block binds open, close, read, write, lseek, mkdir, rmdir, unlink, and rename directly, and a wrapper around each reads errno the way std.os does and hands the failure back as an error. Two pieces cannot be bound directly, stat and directory iteration, because both traffic in C layouts Dusk never reads, so each goes through a shim in the C runtime instead. The path functions at the end touch no C at all. The module lives at lib/std/fs.dusk.
@import std.fsImported names are flat: after @import std.fs you call open_file, file_stat, and path_join with no prefix. See stdlib overview for how imports work in general.
If all you want is a whole small file in one call, you do not need this module. read_file and write_file are global builtins, available with no import, and they handle the open, the loop, and the close for you. Reach for std.fs when you need a descriptor you hold open, a seek, a directory walk, a file’s size, or path arithmetic.
Opening and closing
Section titled “Opening and closing”| Function | Description |
|---|---|
open_file(path: string, flags: int32, mode: int32) -> (int32, error) | Opens path with the given open() flags. Returns the new descriptor, or -1 with an error carrying strerror’s text. |
close_file(fd: int32) -> error | Closes a descriptor opened by open_file. |
mode is the create permission, and it only means anything when flags includes o_creat(). Pass it anyway when you are not creating: the OS ignores an unread vararg, and 0 is the usual thing to write there.
The flags are functions rather than constants, and you bitwise-or them together exactly as you would in C:
| Function | Description |
|---|---|
o_rdonly() -> int32 | Open for reading only. |
o_wronly() -> int32 | Open for writing only. |
o_rdwr() -> int32 | Open for reading and writing. |
o_creat() -> int32 | Create the file if it does not exist. |
o_excl() -> int32 | With o_creat(), fail if the file already exists. |
o_trunc() -> int32 | Truncate an existing file to zero length. |
o_append() -> int32 | Append to the end on every write. |
These carry the Linux and glibc values. Two permission modes come spelled out so you never hand-convert octal to decimal yourself:
| Function | Description |
|---|---|
mode_0644() -> int32 | Owner read and write, group and other read. |
mode_0755() -> int32 | Owner read, write, and execute, group and other read and execute. |
A typical create looks like open_file(path, o_wronly() | o_creat() | o_trunc(), mode_0644()).
Reading, writing, seeking
Section titled “Reading, writing, seeking”| Function | Description |
|---|---|
read_bytes(fd: int32, buf: *void, cap: int64) -> (int64, error) | Reads up to cap bytes into buf. Returns the count read. |
write_bytes(fd: int32, buf: *void, n: int64) -> (int64, error) | Writes n bytes from buf. Returns the count actually written. |
seek(fd: int32, offset: int64, whence: int32) -> (int64, error) | Moves fd’s offset and returns the resulting absolute offset. |
buf is a raw buffer you own and stage yourself, through alloc_bytes for a read or cbuf for a string you are writing out, the same idiom std.async.io’s read_nb uses. Free it when you are done with it.
Two return values here are worth reading closely. A read_bytes of 0 with no error is end of file, not a failure, so a read loop stops on the count rather than on the error. And write_bytes returns the count actually written, which on a short write is less than the n you asked for. The wrapper does not loop for you. If you must write every byte, you loop until the counts add up.
seek takes its whence from the same three functions C does:
| Function | Description |
|---|---|
seek_set() -> int32 | Offset is absolute, from the start of the file. |
seek_cur() -> int32 | Offset is relative to the current position. |
seek_end() -> int32 | Offset is relative to the end of the file. |
Files and directories on disk
Section titled “Files and directories on disk”| Function | Description |
|---|---|
make_dir(path: string, mode: int32) -> error | Creates a directory. mode_0755() is the common case. |
remove_dir(path: string) -> error | Removes an empty directory. |
remove_file(path: string) -> error | Removes a file, or a symlink itself, never its target. |
move_file(old_path: string, new_path: string) -> error | Renames or moves old_path to new_path. |
make_dir’s mode is subject to the process umask, as it always is under mkdir(2). move_file is rename(2) and inherits its contract whole: it replaces new_path if that already exists, and it only works when both names sit on the same filesystem.
The errno convention
Section titled “The errno convention”Every wrapper above follows std.os’s rule, and it is worth knowing why the rule exists rather than just that it does. Dusk never sets errno itself, so a read of it reports whatever the most recent foreign call left behind. That makes the read positional: each wrapper calls os_errno() immediately after the one libc call it names, before anything else can cross the boundary and overwrite it, and only then decides whether it failed. The message on a returned error is errstr’s text for that code, copied into a fresh heap string.
Do not pin that wording in a test. glibc’s strerror text is not fixed across platforms or locales. examples/fs_open_missing.dusk in the language repo checks the portable shape instead: the error exists, and its message is non-empty. See foreign functions for the full errno and errstr reference.
export struct FileStat { size: int64, mode: int64, mtime: int64,}| Function | Description |
|---|---|
file_stat(path: string) -> (FileStat, error) | Reads path’s size, mode, and modification time. |
size is in bytes, mode carries the POSIX type and permission bits together, and mtime is seconds since the epoch, UTC. A failure, most often a missing path, returns a zeroed FileStat beside the error.
This one does not bind stat(2). It goes through cool_stat, a shim in runtime/runtime.c, and the reason is a real constraint rather than a detour. stat(2) hands back its answer in a struct stat, and struct stat’s layout is a C detail that varies by platform and by libc version. Dusk never reads it, and 1.4.1’s new struct-by-value boundary does not help here, because that boundary only accepts a struct whose fields are all C plain and whose layout Dusk therefore knows. So the shim inverts the problem: cool_stat takes three plain int64 out parameters and fills in size, mode, and mtime one word at a time. Nothing about struct stat’s shape ever reaches the Dusk side, and FileStat is an ordinary Dusk struct assembled from three scalars.
Directory iteration
Section titled “Directory iteration”export struct Dir { h: int64,}| Function | Description |
|---|---|
dir_open(path: string) -> (Dir, error) | Opens path for iteration. |
dir_next(d: Dir) -> (string, bool) | The next entry’s bare name, and whether one was found. |
dir_close(d: Dir) -> error | Closes a stream opened by dir_open. |
dir_next returns the entry’s bare name with no path prefix, so you rebuild the full path with path_join if you want one. It skips . and .. for you. The bool is false, with the empty string beside it, once the stream is exhausted. A hard read error mid-walk also reports false, since dir_next carries no error channel of its own and a failure partway through a walk is rare enough that end of stream is the practical reading.
The order entries come back in is readdir’s own, which is not guaranteed to be anything. If you want deterministic output, sort what you collect.
Dir holds its open stream as an int64, and that int64 is the bit pattern of a C DIR*. It looks like a workaround and it is not a stylistic one. Dusk’s == rejects every pointer type outright, with the diagnostic pointers do not compare; compare the values they point to. A C caller null checks opendir’s return to find out whether it failed, and that check is a pointer comparison, so a Dusk wrapper holding a *void would have no way to write it. The shims dodge the check entirely rather than ask the language for one it does not offer: cool_dir_open reports success through a separate int64 status and delivers the stream through an out parameter, and cool_dir_next reports through a length. The handle is opaque. It means something to dir_next and dir_close and to nothing else, and your code never reads or compares it.
Path arithmetic
Section titled “Path arithmetic”| Function | Description |
|---|---|
path_join(dir: string, name: string) -> string | Joins dir and name with exactly one /. |
path_dirname(p: string) -> string | Everything up to the last /, or "." when there is no separator. |
path_basename(p: string) -> string | Everything after the last /, or the whole string when there is no separator. |
path_extension(p: string) -> string | The final component’s extension, without its leading ., or "" when it has none. |
These four are pure Dusk with no foreign call anywhere in them, ported from the same path logic the compiler’s own driver and home modules use to lay out target/dusk-out and resolve DUSK_HOME. They are reproduced here rather than imported so that std.fs carries no dependency on the compiler.
Each returns a fresh heap string you own, so free the result when you are done with it. path_join collapses any trailing separators on dir first, so a directory that already ends in one, or in a run of them, still yields exactly one before name; an empty dir joins to name alone. path_extension treats a leading . on the final component as part of the name rather than an extension boundary, so a dotfile like .bashrc has no extension, and it only searches the final component, so a . in an earlier directory name never leaks in as a false one.
Why file_stat, path_dirname, and path_basename
Section titled “Why file_stat, path_dirname, and path_basename”Three functions here carry a prefix the rest of the module’s wrappers do not, and the changelog records the reason plainly. Dusk gives every exported top level function a bare, unmangled link symbol. libc already owns stat(2), dirname(3), and basename(3), and those are among the very names this module would otherwise reach for. An export named stat would not sit politely beside libc’s. It would win: the module’s own internal calls to stat would resolve to the Dusk definition instead of libc’s, and the result corrupts silently. So the module exports file_stat, path_dirname, and path_basename in the collision’s place. path_extension carries its prefix only for symmetry with the other three, since it has no libc name to collide with at all.
The sharp part is what is not yet there. An export is not checked against what the binary already links, so nothing diagnoses this for you. A later module that picks a colliding name trips the same silent redirect with no error and no warning, and 1.4.1 records that check as left to later work. Until it lands, the burden is on you: if you export a top level function whose name libc also owns, you get the redirect and no diagnostic.
A tour
Section titled “A tour”Write a file through a descriptor, read it back, stat it, take a path apart, and clean up.
@paradigm procedural
@import std.fs@import std.string
func main() -> int32 { path: string = "/tmp/dusk_fs_tour.txt"
// Create and write. cbuf stages the string as a raw C buffer; the // count that comes back is what was actually written, which a real // program loops on rather than trusting. fd, oe := open_file(path, o_wronly() | o_creat() | o_trunc(), mode_0644()) oe.ignore() msg: string = "hello from dusk" wbuf: *raw char = cbuf(msg) n, we := write_bytes(fd, wbuf, str_len(msg)) we.ignore() free(wbuf) ce := close_file(fd) ce.ignore() println(n) // 15
// Read it back. alloc_bytes stages the buffer; a read of 0 with no // error would be end of file, not a failure. fd2, oe2 := open_file(path, o_rdonly(), 0) oe2.ignore() rbuf: *raw char = alloc_bytes(64) rn, re := read_bytes(fd2, rbuf, 63) re.ignore() rbuf[rn] = 0 println(cstr(rbuf)) // hello from dusk free(rbuf) ce2 := close_file(fd2) ce2.ignore()
// Three plain scalars, never struct stat's own layout. st, se := file_stat(path) se.ignore() println(st.size) // 15
// Pure Dusk, no foreign call. Each result is a fresh heap string. joined: string = path_join("/tmp", "dusk_fs_tour.txt") println(joined) // /tmp/dusk_fs_tour.txt println(path_dirname(joined)) // /tmp println(path_basename(joined)) // dusk_fs_tour.txt println(path_extension(joined)) // txt free(joined)
ue := remove_file(path) ue.ignore() return 0}See also
Section titled “See also”- Foreign functions: the boundary this module is built on, and the normative
errnoanderrstrreference. - Memory management: the
*raw Tand*voidlayersread_bytesandwrite_bytescross on, and thealloc_bytesandfreepair that stages a buffer. - std.string:
cbuf,str_len, and the rest of the string surface the path functions build on. - stdlib overview: the full module list and how imports resolve.