Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/docs/namespaces/db.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ Returns a sorted `sym` vector of the table names available under a parted `db_ro
(map (fn [t] (.db.parted.get "/data/db" t)) (.db.parted.tables "/data/db"))
```

Errors: `domain` (arity != 1), `type` (root not a string), `io` (root unreadable or not a parted root — no partition directories).
An existing root with no partition directories (a freshly-created or non-parted directory) lists **no tables** — the call returns an empty `sym` vector rather than failing.

Errors: `domain` (arity != 1), `type` (root not a string), `io` (root missing or unreadable — `opendir` fails).

## `.db.parted.fill` { #db-parted-fill }

Expand All @@ -100,7 +102,7 @@ Returns a sorted `sym` vector of the partition names that were filled (an **empt
(select {from: (.db.parted.get "/data/db" 'news)})
```

The filled copies are empty, so aggregate results across the db are unchanged — only the on-disk uniformity is. Errors: `domain` (arity != 1), `type` (root not a string), `io` (not a parted root), plus any `oom`/`corrupt` surfaced while reading a template or writing a copy.
The filled copies are empty, so aggregate results across the db are unchanged — only the on-disk uniformity is. An existing root with no partition directories is a no-op: the call returns an empty `sym` vector. Errors: `domain` (arity != 1), `type` (root not a string), `io` (root missing or unreadable), plus any `oom`/`corrupt` surfaced while reading a template or writing a copy.

## See also

Expand Down
45 changes: 45 additions & 0 deletions docs/docs/namespaces/fs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# `.fs.*` — filesystem

Minimal filesystem-metadata primitives. The namespace is deliberately small: two builtins cover the common needs (file size, directory listing) and the predicate cases (exists / is-file / is-dir) are reached by `try`-wrapping `.fs.size` or `.fs.list`.

!!! note "Always available"
`.fs.size` and `.fs.list` are read-only and unrestricted — they remain callable for IPC peers even when the server is started with `-U`.

## Reference

| Function | Arity | Flags | Description |
|---|---|---|---|
| [`.fs.size`](#fs-size) | unary | — | File size in bytes. |
| [`.fs.list`](#fs-list) | unary | — | Sorted sym vector of directory entries. |

## `.fs.size` { #fs-size }

Signature: `(.fs.size "path")`. Returns the file size in bytes as an `i64`.

Errors: `type` (arg not a string), `io` (path missing or names a directory rather than a file). Wrap in `try` to distinguish "doesn't exist" from "is a directory" without parsing error messages — both surface as `io`.

```lisp
(.fs.size "/etc/hosts")
;; => 213

(try (.fs.size "/missing"))
;; => <error: io>
```

## `.fs.list` { #fs-list }

Signature: `(.fs.list "path")`. Returns a sym vector of entries in the directory, sorted lexicographically. The `.` and `..` entries are filtered out.

Errors: `type` (arg not a string), `io` (path is not a directory, or doesn't exist).

```lisp
(.fs.list "/etc")
;; => ['hosts 'passwd 'resolv.conf ...]
```

Use this as a building block — `find`-style recursive walks can be expressed by mapping `.fs.list` over the result and filtering with `.fs.size` / `try`.

## See also

- [`.os.*`](os.md) — process-environment access (`getenv` / `setenv`).
- [`.sys.exec`](sys.md#sys-exec) — shell out for anything not covered here (test predicates, deletes, recursive operations).
5 changes: 3 additions & 2 deletions docs/docs/namespaces/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ Rayfall's builtins are organised under dotted namespaces. Names beginning with `
| [`.col.*`](col.md) | Foreign-key style column-to-table linking and dotted dereference. |
| [`.csv.*`](csv.md) | CSV import/export — in-memory, splayed-on-disk, and partitioned variants. |
| [`.db.*`](db.md) | On-disk table I/O: splayed and partitioned `get` / `set`. |
| [`.fs.*`](fs.md) | Filesystem metadata: `size`, `list`. |
| [`.graph.*`](graph.md) | Graph builders and algorithms (PageRank, Louvain, Dijkstra, MST, BFS/DFS, expand, …). |
| [`.idx.*`](idx.md) | Accelerator indexes: bloom, hash, sort, zone. |
| [`.ipc.*`](ipc.md) | TCP client IPC and the server connection-hook accessor. |
| [`.log.*`](log.md) | Write-ahead log: open, write, sync, snapshot, roll, replay, validate. |
| [`.os.*`](os.md) | Filesystem and environment: `getenv`, `setenv`, `list`, `size`. |
| [`.os.*`](os.md) | Process environment: `getenv`, `setenv`. |
| [`.repl.*`](repl.md) | Interactive REPL control — attach the local REPL to a remote server. |
| [`.sys.*`](sys.md) | System info and shell-style commands: build, info, mem, gc, exec, listen, timeit, env, cmd. |
| [`.time.*`](time.md) | Monotonic clock and timer scheduler. |
Expand All @@ -31,4 +32,4 @@ When the server is started with `-U <password>`, the following dotted builtins a
- `.sys.exec`, `.sys.cmd`, `.sys.listen`
- `.time.timer.set`, `.time.timer.del`

`.col.*`, `.graph.*`, `.idx.*`, and pure read/inspect builtins (`.db.splayed.get`, `.db.parted.get`, `.db.parted.tables`, `.os.size`, `.os.list`, `.log.write`, `.log.sync`, `.log.validate`, `.sys.build`, `.sys.info`, `.sys.mem`, `.sys.gc`, `.sys.env`, `.sys.timeit`, `.ipc.handle`, `.time.now`) are always available.
`.col.*`, `.graph.*`, `.idx.*`, and pure read/inspect builtins (`.db.splayed.get`, `.db.parted.get`, `.db.parted.tables`, `.fs.size`, `.fs.list`, `.log.write`, `.log.sync`, `.log.validate`, `.sys.build`, `.sys.info`, `.sys.mem`, `.sys.gc`, `.sys.env`, `.sys.timeit`, `.ipc.handle`, `.time.now`) are always available.
38 changes: 5 additions & 33 deletions docs/docs/namespaces/os.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
# `.os.*` — filesystem and environment
# `.os.*` — process environment

Minimal filesystem and process-environment primitives. The namespace is deliberately small: four builtins cover the common needs (read/write env, file size, directory listing) and the predicate cases (exists / is-file / is-dir) are reached by `try`-wrapping `.os.size` or `.os.list`.
Process-environment access. The namespace is deliberately small: two builtins read and write environment variables. Filesystem metadata lives in its own [`.fs.*`](fs.md) namespace.

!!! note "Restricted under `-U`"
`.os.getenv` and `.os.setenv` are `RAY_FN_RESTRICTED` (environment is process-global state). `.os.size` and `.os.list` are read-only and unrestricted.
`.os.getenv` and `.os.setenv` are `RAY_FN_RESTRICTED` (environment is process-global state).

## Reference

| Function | Arity | Flags | Description |
|---|---|---|---|
| [`.os.getenv`](#os-getenv) | unary | restricted | Read an environment variable as a string. |
| [`.os.setenv`](#os-setenv) | binary | restricted | Set an environment variable. |
| [`.os.size`](#os-size) | unary | — | File size in bytes. |
| [`.os.list`](#os-list) | unary | — | Sorted sym vector of directory entries. |

## `.os.getenv` { #os-getenv }

Expand All @@ -38,33 +36,7 @@ Errors: `type` (either arg not a string), `domain` (null pointer).
(.os.setenv "RAY_DEBUG" "1")
```

## `.os.size` { #os-size }

Signature: `(.os.size "path")`. Returns the file size in bytes as an `i64`.

Errors: `type` (arg not a string), `io` (path missing or names a directory rather than a file). Wrap in `try` to distinguish "doesn't exist" from "is a directory" without parsing error messages — both surface as `io`.

```lisp
(.os.size "/etc/hosts")
;; => 213

(try (.os.size "/missing"))
;; => <error: io>
```

## `.os.list` { #os-list }

Signature: `(.os.list "path")`. Returns a sym vector of entries in the directory, sorted lexicographically. The `.` and `..` entries are filtered out.

Errors: `type` (arg not a string), `io` (path is not a directory, or doesn't exist).

```lisp
(.os.list "/etc")
;; => ['hosts 'passwd 'resolv.conf ...]
```

Use this as a building block — `find`-style recursive walks can be expressed by mapping `.os.list` over the result and filtering with `.os.size` / `try`.

## See also

- [`.sys.exec`](sys.md#sys-exec) — shell out for anything not covered here (test predicates, deletes, recursive operations).
- [`.fs.*`](fs.md) — filesystem metadata (`size` / `list`).
- [`.sys.exec`](sys.md#sys-exec) — shell out for anything not covered here.
3 changes: 2 additions & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,12 @@ nav:
- .col — Column linking: docs/namespaces/col.md
- .csv — CSV I/O: docs/namespaces/csv.md
- .db — Tables on disk: docs/namespaces/db.md
- .fs — Filesystem: docs/namespaces/fs.md
- .graph — Graph algorithms: docs/namespaces/graph.md
- .idx — Indexes: docs/namespaces/idx.md
- .ipc — IPC client: docs/namespaces/ipc.md
- .log — Write-ahead log: docs/namespaces/log.md
- .os — Filesystem & env: docs/namespaces/os.md
- .os — Process env: docs/namespaces/os.md
- .repl — REPL control: docs/namespaces/repl.md
- .sys — System info: docs/namespaces/sys.md
- .time — Clock & timers: docs/namespaces/time.md
Expand Down
11 changes: 6 additions & 5 deletions src/lang/eval.c
Original file line number Diff line number Diff line change
Expand Up @@ -2929,11 +2929,12 @@ static void ray_register_builtins(void) {
/* OS env / process interaction under `.os.*` */
register_unary( ".os.getenv", RAY_FN_RESTRICTED, ray_getenv_fn);
register_binary(".os.setenv", RAY_FN_RESTRICTED, ray_setenv_fn);
/* Filesystem metadata (issue #36): size + listing. Predicates
* (exists / is-file / is-dir) are reachable via `try` on these
* or via shell fallback through `.sys.cmd`. */
register_unary( ".os.size", RAY_FN_NONE, ray_os_size_fn);
register_unary( ".os.list", RAY_FN_NONE, ray_os_list_fn);

/* Filesystem metadata under `.fs.*` (issue #36): size + listing.
* Predicates (exists / is-file / is-dir) are reachable via `try` on
* these or via shell fallback through `.sys.cmd`. */
register_unary( ".fs.size", RAY_FN_NONE, ray_fs_size_fn);
register_unary( ".fs.list", RAY_FN_NONE, ray_fs_list_fn);

/* IPC client primitives under `.ipc.*` */
register_vary( ".ipc.open", RAY_FN_RESTRICTED, ray_hopen_fn);
Expand Down
6 changes: 3 additions & 3 deletions src/lang/internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -536,11 +536,11 @@ ray_t* ray_sys_listen_fn(ray_t* x);
ray_t* ray_sys_timeit_fn(ray_t** args, int64_t n);
ray_t* ray_sys_env_fn(ray_t** args, int64_t n);
ray_t* ray_getenv_fn(ray_t* x);
/* Filesystem metadata under .os.* (issue #36). Lean two: size +
/* Filesystem metadata under .fs.* (issue #36). Lean two: size +
* directory-list. Existence/is-file/is-dir reachable via try on
* either of these, or via the shell fallback in .sys.cmd. */
ray_t* ray_os_size_fn(ray_t* x);
ray_t* ray_os_list_fn(ray_t* x);
ray_t* ray_fs_size_fn(ray_t* x);
ray_t* ray_fs_list_fn(ray_t* x);
ray_t* ray_setenv_fn(ray_t* name, ray_t* val);
ray_t* ray_quote_fn(ray_t** args, int64_t n);
ray_t* ray_return_fn(ray_t** args, int64_t n);
Expand Down
22 changes: 11 additions & 11 deletions src/ops/system.c
Original file line number Diff line number Diff line change
Expand Up @@ -236,15 +236,15 @@ ray_t* ray_fill_parted_fn(ray_t** args, int64_t n) {
return ray_parted_fill(root);
}

/* stat/dirent used by the .os.* filesystem metadata builtins below. */
/* stat/dirent used by the .fs.* filesystem metadata builtins below. */
#include <sys/stat.h>
#include <dirent.h>

/* ══════════════════════════════════════════
* Filesystem metadata: .os.size / .os.list
* Filesystem metadata: .fs.size / .fs.list
*
* Issue #36 asked for size + existence + listing primitives. We
* keep just two — `.os.size` and `.os.list` — because every other
* keep just two — `.fs.size` and `.fs.list` — because every other
* predicate (exists, is-file, is-dir) is reachable either via
* try-on-error against these or via the existing shell fallback
* (`(.sys.cmd "test -e p")` etc.). Both errors are flagged "io"
Expand All @@ -253,19 +253,19 @@ ray_t* ray_fill_parted_fn(ray_t** args, int64_t n) {
* message.
* ══════════════════════════════════════════ */

/* (.os.size "path") → i64 file size in bytes. Errors with "io"
/* (.fs.size "path") → i64 file size in bytes. Errors with "io"
* when the path doesn't exist or names a directory — `try` it if
* the caller wants those treated as "not a file" rather than a
* hard error. */
ray_t* ray_os_size_fn(ray_t* x) {
ray_t* ray_fs_size_fn(ray_t* x) {
if (!ray_is_atom(x) || x->type != -RAY_STR)
return ray_error("type", ".os.size expects a string path");
return ray_error("type", ".fs.size expects a string path");
char path[1024];
/* x is already a STR atom here; str_to_cpath fails only when the path
* is empty or exceeds the buffer — a domain/range issue, not a type
* mismatch (old code: "type" → "domain"). */
if (!str_to_cpath(x, path, sizeof(path)))
return ray_error("domain", ".os.size path is empty or too long, got %lld bytes", (long long)ray_str_len(x));
return ray_error("domain", ".fs.size path is empty or too long, got %lld bytes", (long long)ray_str_len(x));

struct stat st;
if (stat(path, &st) != 0)
Expand All @@ -284,19 +284,19 @@ static int dir_entry_cmp(const void* a, const void* b) {
return strcmp(sa, sb);
}

/* (.os.list "path") → sym vec of entries, sorted, with `.` and `..`
/* (.fs.list "path") → sym vec of entries, sorted, with `.` and `..`
* filtered out. Errors with "io" if the path isn't a directory or
* doesn't exist — caller can use that as a file/dir discriminator
* via `try` when they don't want to shell out for the predicate. */
ray_t* ray_os_list_fn(ray_t* x) {
ray_t* ray_fs_list_fn(ray_t* x) {
if (!ray_is_atom(x) || x->type != -RAY_STR)
return ray_error("type", ".os.list expects a string path");
return ray_error("type", ".fs.list expects a string path");
char path[1024];
/* x is already a STR atom here; str_to_cpath fails only when the path
* is empty or exceeds the buffer — a domain/range issue, not a type
* mismatch (old code: "type" → "domain"). */
if (!str_to_cpath(x, path, sizeof(path)))
return ray_error("domain", ".os.list path is empty or too long, got %lld bytes", (long long)ray_str_len(x));
return ray_error("domain", ".fs.list path is empty or too long, got %lld bytes", (long long)ray_str_len(x));

DIR* d = opendir(path);
if (!d) return ray_error("io", "%s: %s", path, strerror(errno));
Expand Down
28 changes: 25 additions & 3 deletions src/store/part.c
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,14 @@ static ray_err_t collect_part_dirs(const char* db_root, char*** out_dirs,
}

if (part_count == 0) {
/* Directory opened fine but holds no partition directories — an
* EMPTY (or non-parted) db, not an I/O failure. Report success
* with a zero count and let each caller decide what "empty" means
* (tables/fill → empty result; get → error, nothing to read). */
free(part_dirs);
return RAY_ERR_IO;
*out_dirs = NULL;
*out_count = 0;
return RAY_OK;
}

/* Sort partition names for deterministic order.
Expand Down Expand Up @@ -244,6 +250,14 @@ ray_t* ray_read_parted(const char* db_root, const char* table_name) {
if (trace)
fprintf(stderr, "parted.get: parts=%" PRId64 "\n", part_count);

/* No partitions: there is no named table to read. Unlike
* .db.parted.tables (which lists nothing), reading data from an empty
* or non-parted root is a genuine error. */
if (part_count <= 0) {
if (dom) ray_sym_domain_release(dom);
return ray_error("io", "parted %s: no partition directories", db_root);
}

/* Open each partition via ray_read_splayed */
ray_t* part_err = NULL;
ray_t** part_tables = (ray_t**)ray_sys_alloc((size_t)part_count * sizeof(ray_t*));
Expand Down Expand Up @@ -569,8 +583,12 @@ ray_t* ray_parted_tables(const char* db_root) {
return ray_error(ray_err_code_str(e),
"parted %s: cannot enumerate partition directories", db_root);
if (part_count <= 0) {
/* Existing-but-empty (or non-parted) root → no tables. Return an
* empty SYM vector rather than an error: a freshly-created db root
* has zero tables, and that's a friendlier answer than failing. */
free(part_dirs);
return ray_error("io", "parted %s: no partition directories", db_root);
ray_t* empty = ray_vec_new(RAY_SYM, 0);
return empty ? empty : ray_error("oom", NULL);
}

/* Only the last (most recent) partition is needed; release the rest. */
Expand Down Expand Up @@ -626,8 +644,12 @@ ray_t* ray_parted_fill(const char* db_root) {
return ray_error(ray_err_code_str(e),
"parted %s: cannot enumerate partition directories", db_root);
if (part_count <= 0) {
/* Empty (or non-parted) root → nothing to fill. Matches the
* "empty vector when nothing needed fixing" contract, so a fill on
* a fresh db root is a friendly no-op rather than an error. */
free(part_dirs);
return ray_error("io", "parted %s: no partition directories", db_root);
ray_t* empty = ray_vec_new(RAY_SYM, 0);
return empty ? empty : ray_error("oom", NULL);
}

/* Shared root symfile — pass to load/save so SYM columns intern against
Expand Down
11 changes: 9 additions & 2 deletions test/rfl/system/db_get.rfl
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,15 @@
(count (.db.parted.tables "/tmp/rfl_get_parted/")) -- 2
;; a discovered name feeds straight back into .db.parted.get.
(count (.db.parted.get "/tmp/rfl_get_parted/" (at (.db.parted.tables "/tmp/rfl_get_parted/") 0))) -- 4
;; not a parted root (splayed root has no partition dirs) -> io error.
(.db.parted.tables "/tmp/rfl_get_splayed/") !- io
;; existing-but-empty root (splayed root has no partition dirs) -> empty
;; list, not an error. A directory that simply isn't a parted db lists
;; zero tables; that's friendlier than failing.
(count (.db.parted.tables "/tmp/rfl_get_splayed/")) -- 0
;; a freshly-created empty directory likewise lists nothing.
(.sys.exec "mkdir -p /tmp/rfl_get_empty")
(count (.db.parted.tables "/tmp/rfl_get_empty")) -- 0
(.sys.exec "rm -rf /tmp/rfl_get_empty")
;; a genuinely missing directory is still an io error (opendir fails).
(.db.parted.tables "/tmp/rfl_get_does_not_exist/") !- io

;; .db.parted.tables reads the LAST partition, so a table added in a later
Expand Down
Loading
Loading