commit 9e2ca18ae08c67d987b537c7a8ac9abef559cb2f
parent 12abde45be05f4a725b7755c7bf389b23211c5f5
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Wed, 23 Sep 2026 21:21:16 -0300
path: add the file-system vocabulary a script reaches for
Reading a config, writing a report and walking a tree each take several
core:os calls with error handling between them. expand resolves a
home-relative path, mkdirs makes a directory tree, read, read_lines,
write and append_file move whole files, list and walk visit a directory
or a tree, and temp_dir and same answer the two questions scripts ask
about paths. state_dir, config_dir, cache_dir and log_dir find the
per-user folders on Linux, macOS and Windows so a script keeps its files
where the platform expects them. Every proc that can fail returns an
os.Error, so must composes with all of them.
Diffstat:
| A | path/path.odin | | | 226 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | path/path_test.odin | | | 82 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
2 files changed, 308 insertions(+), 0 deletions(-)
diff --git a/path/path.odin b/path/path.odin
@@ -0,0 +1,226 @@
+/*
+Package path is the file-system vocabulary a script reaches for: expand a
+home-relative path, make a directory tree, read and write whole files, list
+or walk a tree, and find the per-user state, config, cache and log folders on
+every platform.
+
+Every proc that can fail returns an os.Error so `must` composes:
+
+ cfg := must(path.read(path.expand("~/.config/tool/cfg.json")))
+ must(path.mkdirs(path.state_dir("tool")))
+ must(path.write(out, rendered))
+
+Pure helpers (expand, join, base, dir, ext, stem, same) never fail.
+*/
+package path
+
+import "base:runtime"
+import "core:os"
+import "core:path/filepath"
+import "core:slice"
+import "core:strings"
+
+// Re-exports, so a script needs one import for path work.
+base :: filepath.base
+dir :: filepath.dir
+ext :: filepath.ext
+stem :: filepath.stem
+clean :: filepath.clean
+is_abs :: filepath.is_abs
+exists :: os.exists
+is_dir :: os.is_dir
+is_file :: os.is_file
+remove :: os.remove
+rename :: os.rename
+copy :: os.copy_file
+glob :: os.glob
+remove_all :: os.remove_all
+
+SEPARATOR :: filepath.SEPARATOR
+
+// home returns the current user's home directory.
+home :: proc(allocator := context.allocator) -> string {
+ h, err := os.user_home_dir(allocator)
+ if err != nil {
+ return ""
+ }
+ return h
+}
+
+// expand replaces a leading "~" or "~/" with the home directory and cleans
+// the result. Other paths come back cleaned.
+expand :: proc(p: string, allocator := context.allocator) -> string {
+ if p == "~" {
+ return home(allocator)
+ }
+ if strings.has_prefix(p, "~/") || strings.has_prefix(p, `~\`) {
+ return join(home(context.temp_allocator), p[2:], allocator = allocator)
+ }
+ cleaned, _ := filepath.clean(p, allocator)
+ return cleaned
+}
+
+// join joins path elements with the platform separator and cleans the result.
+join :: proc(elems: ..string, allocator := context.allocator) -> string {
+ s, _ := filepath.join(elems, allocator)
+ return s
+}
+
+// abs makes p absolute against the working directory.
+abs :: proc(p: string, allocator := context.allocator) -> (string, os.Error) {
+ return filepath.abs(p, allocator)
+}
+
+// mkdirs creates p and every missing parent. An existing directory is not
+// an error.
+mkdirs :: proc(p: string) -> os.Error {
+ err := os.make_directory_all(p)
+ if err == os.General_Error.Exist && os.is_dir(p) {
+ return nil
+ }
+ return err
+}
+
+// read returns the whole file as a string.
+read :: proc(p: string, allocator := context.allocator) -> (string, os.Error) {
+ data, err := os.read_entire_file_from_path(p, allocator)
+ return string(data), err
+}
+
+// read_lines returns the file split into lines, without line endings.
+read_lines :: proc(p: string, allocator := context.allocator) -> ([]string, os.Error) {
+ text, err := read(p, allocator)
+ if err != nil {
+ return nil, err
+ }
+ trimmed := strings.trim_right(text, "\r\n")
+ if trimmed == "" {
+ return nil, nil
+ }
+ parts, _ := strings.split_lines(trimmed, allocator)
+ for &part in parts {
+ part = strings.trim_right(part, "\r")
+ }
+ return parts, nil
+}
+
+// write replaces the file's contents, creating parents as needed.
+write :: proc(p: string, data: string) -> os.Error {
+ if err := mkdirs(filepath.dir(p)); err != nil {
+ return err
+ }
+ return os.write_entire_file(p, data)
+}
+
+// append_file adds data to the end of the file, creating it if needed.
+append_file :: proc(p: string, data: string) -> os.Error {
+ if err := mkdirs(filepath.dir(p)); err != nil {
+ return err
+ }
+ f, err := os.open(p, {.Write, .Append, .Create}, os.Permissions_Read_All + {.Write_User})
+ if err != nil {
+ return err
+ }
+ defer os.close(f)
+ _, werr := os.write_string(f, data)
+ return werr
+}
+
+// list returns the names in a directory, sorted.
+list :: proc(p: string, allocator := context.allocator) -> ([]string, os.Error) {
+ infos, err := os.read_all_directory_by_path(p, context.temp_allocator)
+ if err != nil {
+ return nil, err
+ }
+ names := make([]string, len(infos), allocator)
+ for info, i in infos {
+ names[i] = strings.clone(info.name, allocator)
+ }
+ slice.sort(names)
+ return names, nil
+}
+
+// walk returns the full path of every regular file under root, depth first,
+// sorted. Directories are descended but not listed.
+walk :: proc(root: string, allocator := context.allocator) -> ([]string, os.Error) {
+ w := os.walker_create(root)
+ defer os.walker_destroy(&w)
+ files := make([dynamic]string, allocator)
+ for info in os.walker_walk(&w) {
+ if info.type == .Regular {
+ append(&files, strings.clone(info.fullpath, allocator))
+ }
+ }
+ if _, err := os.walker_error(&w); err != nil {
+ return files[:], err
+ }
+ slice.sort(files[:])
+ return files[:], nil
+}
+
+// temp_dir creates a fresh directory under the system temp location. The
+// caller removes it with remove_all.
+temp_dir :: proc(prefix := "odin-", allocator := context.allocator) -> (string, os.Error) {
+ pattern := strings.concatenate({prefix, "*"}, context.temp_allocator)
+ return os.make_directory_temp("", pattern, allocator)
+}
+
+// same reports whether two paths name the same location after cleaning. Case
+// is folded on Windows and macOS, whose default file systems (NTFS, APFS as
+// shipped) are case-insensitive; other volumes on those systems may differ.
+same :: proc(a, b: string) -> bool {
+ ca, _ := filepath.clean(a, context.temp_allocator)
+ cb, _ := filepath.clean(b, context.temp_allocator)
+ when ODIN_OS == .Windows || ODIN_OS == .Darwin {
+ return strings.equal_fold(ca, cb)
+ } else {
+ return ca == cb
+ }
+}
+
+// Per-user application directories, created on first use. The base folders
+// come from core:os user_*_dir, whose doc comments in core/os/user.odin give:
+//
+// Linux macOS Windows
+// state_dir ~/.local/state/app ~/Library/Application Support %LOCALAPPDATA%\app
+// config_dir ~/.config/app ~/Library/Application Support %LOCALAPPDATA%\app
+// cache_dir ~/.cache/app ~/Library/Caches/app %LOCALAPPDATA%\app
+// log_dir ~/.local/state/app ~/Library/Logs/app %LOCALAPPDATA%\app
+state_dir :: proc(app: string, allocator := context.allocator) -> (string, os.Error) {
+ return app_dir(os.user_state_dir, app, allocator)
+}
+
+config_dir :: proc(app: string, allocator := context.allocator) -> (string, os.Error) {
+ base_dir, err := os.user_config_dir(context.temp_allocator)
+ if err != nil {
+ return "", err
+ }
+ return ensure(join(base_dir, app, allocator = allocator))
+}
+
+cache_dir :: proc(app: string, allocator := context.allocator) -> (string, os.Error) {
+ return app_dir(os.user_cache_dir, app, allocator)
+}
+
+log_dir :: proc(app: string, allocator := context.allocator) -> (string, os.Error) {
+ return app_dir(os.user_log_dir, app, allocator)
+}
+
+// ---- internals ----------------------------------------------------------
+
+Dir_Proc :: #type proc(allocator: runtime.Allocator) -> (string, os.Error)
+
+app_dir :: proc(base_of: Dir_Proc, app: string, allocator: runtime.Allocator) -> (string, os.Error) {
+ base_dir, err := base_of(context.temp_allocator)
+ if err != nil {
+ return "", err
+ }
+ return ensure(join(base_dir, app, allocator = allocator))
+}
+
+ensure :: proc(p: string) -> (string, os.Error) {
+ if err := mkdirs(p); err != nil {
+ return p, err
+ }
+ return p, nil
+}
diff --git a/path/path_test.odin b/path/path_test.odin
@@ -0,0 +1,82 @@
+package path
+
+import "core:os"
+import "core:strings"
+import "core:testing"
+
+@(test)
+expand_home :: proc(t: ^testing.T) {
+ h := home(context.temp_allocator)
+ testing.expect(t, len(h) > 0, "home must resolve")
+ testing.expect_value(t, expand("~", context.temp_allocator), h)
+ testing.expect(t, strings.has_prefix(expand("~/x", context.temp_allocator), h))
+ testing.expect(t, strings.has_suffix(expand("~/x", context.temp_allocator), "x"))
+ testing.expect_value(t, expand("a/../b", context.temp_allocator), "b")
+}
+
+@(test)
+files_roundtrip :: proc(t: ^testing.T) {
+ root, err := temp_dir("jfm-path-test-", context.temp_allocator)
+ testing.expect_value(t, err, nil)
+ defer remove_all(root)
+
+ nested := join(root, "a", "b", allocator = context.temp_allocator)
+ testing.expect_value(t, mkdirs(nested), nil)
+ testing.expect_value(t, mkdirs(nested), nil) // existing is fine
+ testing.expect(t, is_dir(nested))
+
+ file := join(nested, "f.txt", allocator = context.temp_allocator)
+ testing.expect_value(t, write(file, "one\r\ntwo\n"), nil)
+ testing.expect_value(t, append_file(file, "three\n"), nil)
+
+ text, rerr := read(file, context.temp_allocator)
+ testing.expect_value(t, rerr, nil)
+ testing.expect_value(t, text, "one\r\ntwo\nthree\n")
+
+ ls, lerr := read_lines(file, context.temp_allocator)
+ testing.expect_value(t, lerr, nil)
+ testing.expect_value(t, len(ls), 3)
+ if len(ls) == 3 {
+ testing.expect_value(t, ls[0], "one")
+ testing.expect_value(t, ls[2], "three")
+ }
+
+ other := join(root, "z.txt", allocator = context.temp_allocator)
+ testing.expect_value(t, write(other, "z"), nil)
+ names, nerr := list(root, context.temp_allocator)
+ testing.expect_value(t, nerr, nil)
+ testing.expect_value(t, len(names), 2)
+ if len(names) == 2 {
+ testing.expect_value(t, names[0], "a")
+ testing.expect_value(t, names[1], "z.txt")
+ }
+
+ files, werr := walk(root, context.temp_allocator)
+ testing.expect_value(t, werr, nil)
+ testing.expect_value(t, len(files), 2)
+ if len(files) == 2 {
+ testing.expect(t, strings.has_suffix(files[0], "f.txt"))
+ testing.expect(t, strings.has_suffix(files[1], "z.txt"))
+ }
+
+ _, missing := read(join(root, "missing", allocator = context.temp_allocator), context.temp_allocator)
+ testing.expect(t, missing != nil)
+ testing.expect(t, missing == os.General_Error.Not_Exist)
+}
+
+@(test)
+same_paths :: proc(t: ^testing.T) {
+ testing.expect(t, same("a/b/../c", "a/c"))
+ testing.expect(t, !same("a/c", "a/d"))
+ when ODIN_OS == .Windows || ODIN_OS == .Darwin {
+ testing.expect(t, same("A/C", "a/c"))
+ }
+}
+
+@(test)
+app_dirs_exist :: proc(t: ^testing.T) {
+ d, err := cache_dir("jfm-path-test", context.temp_allocator)
+ testing.expect_value(t, err, nil)
+ testing.expect(t, is_dir(d))
+ remove_all(d)
+}