jm

Odin for scripts: small packages and a runner, on core: only
Log | Files | Refs | README

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:
Apath/path.odin | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apath/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) +}