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
2 changes: 1 addition & 1 deletion docs/docs/language/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ Cross-temporal comparisons are supported: dates, times, and timestamps are all c
| `read-bytes` | unary | Read file contents as a `U8` byte vector | `(read-bytes "file.bin")` |
| `write` | binary | Write a string to a file | `(write "file.txt" "content")` |
| `write-bytes` | binary | Write a `U8` byte vector to a file | `(write-bytes "file.bin" bytes)` |
| `load` | unary | Load and evaluate a Rayfall script | `(load "lib.rfl")` |
| `load` | unary | Load and evaluate a Rayfall script; a relative path is tried against the working directory, then below `RAYFORCE_HOME` | `(load "lib.rfl")` |

## Control Flow

Expand Down
7 changes: 7 additions & 0 deletions docs/docs/namespaces/sys.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,17 @@ Signature: `(.sys.args)`. Returns the process's command-line arguments as a dict
| `querylog` | bool | `-Q` | Query-statistics logging enabled at startup. |
| `interactive` | bool | `-i` | Force the REPL after a script. |
| `log` | str | `-l` / `-L` | Journal base path; empty if none. |
| `source` | str | — | The file currently being evaluated: the innermost `load`, or the command-line script, by the path that was actually opened. Empty at the REPL, under a pipe, or in a hook or timer that runs outside any file. `file` is the script named on the command line (`$0`); `source` follows nested loads (`$BASH_SOURCE`). |
| `user` | dict | after `--` | The application's own arguments. |

The top-level schema is **stable** — every launcher key is always present with its effective value (the default when the flag wasn't passed), so `(get (.sys.args) 'port)` never misses. Auth passwords (`-u` / `-U`) are deliberately **not** exposed.

```lisp
;; load a sibling of the running file, whichever directory it was started from
(set here (str-join (drop -1 (split (at (.sys.args) 'source) "/")) "/"))
(load (concat here "/lib.rfl"))
```

**`user` parsing.** Tokens after `--` are paired `-key value` / `--key value`: a token starting with `-` is a key (leading dashes stripped to a symbol), and the next token is its value — unless that token also starts with `-`, in which case the value is the empty string (a bare flag). Duplicate keys keep the last value.

```lisp
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/reference/all-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,7 @@ Printing, file I/O, CSV loading, and script execution.
| `read-bytes` | unary | restricted | Read file contents as a `U8` byte vector | `(read-bytes "file.bin")` |
| `write` | binary | restricted | Write a string to a file | `(write "file.txt" "content")` |
| `write-bytes` | binary | restricted | Write a `U8` byte vector to a file | `(write-bytes "file.bin" bytes)` |
| `load` | unary | restricted | Load and evaluate a Rayfall script file | `(load "lib.rfl")` |
| `load` | unary | restricted | Load and evaluate a Rayfall script file. A relative path is tried against the working directory, then below `RAYFORCE_HOME` when set; absolute paths are used as given; a failure names every path tried | `(load "lib.rfl")` |
| `exit` | unary | restricted | Exit the process with status code | `(exit 0)` |
| `resolve` | variadic | special | Resolve a symbol in the current scope | `(resolve 'x)` |
| `timeit` | variadic | special | Benchmark an expression (prints elapsed time) | `(timeit (sum (til 1000000)))` |
Expand Down
2 changes: 1 addition & 1 deletion src/app/repl.c
Original file line number Diff line number Diff line change
Expand Up @@ -819,7 +819,7 @@ static void eval_and_print(ray_term_t* term, const char* input,
ray_eval_clear_interrupt();
if (term) ray_term_eval_begin(term);

ray_t* nfo = ray_nfo_create("repl", 4, input, strlen(input));
ray_t* nfo = ray_nfo_create(RAY_NFO_REPL_NAME, strlen(RAY_NFO_REPL_NAME), input, strlen(input));
ray_clear_error_trace();

ray_t* parsed = ray_parse_with_nfo(input, nfo);
Expand Down
2 changes: 1 addition & 1 deletion src/lang/eval.c
Original file line number Diff line number Diff line change
Expand Up @@ -4028,7 +4028,7 @@ ray_t* ray_eval(ray_t* obj) {

ray_t* ray_eval_str(const char* source) {
ray_clear_error_trace();
ray_t* nfo = ray_nfo_create("repl", 4, source, strlen(source));
ray_t* nfo = ray_nfo_create(RAY_NFO_REPL_NAME, strlen(RAY_NFO_REPL_NAME), source, strlen(source));
ray_t* parsed = ray_parse_with_nfo(source, nfo);
if (RAY_IS_ERR(parsed)) { ray_release(nfo); return parsed; }

Expand Down
4 changes: 4 additions & 0 deletions src/lang/nfo.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ typedef union ray_span_t {
* [3] vals (RAY_I64 vector — span ids)
*/

/* Filename an nfo carries for input that came from no file — the REPL,
* ray_eval_str, a pipe. (.sys.args) reports `source` as empty for it. */
#define RAY_NFO_REPL_NAME "repl"

#define NFO_FILENAME(nfo) ray_list_get((nfo), 0)
#define NFO_SOURCE(nfo) ray_list_get((nfo), 1)
#define NFO_KEYS(nfo) ray_list_get((nfo), 2)
Expand Down
60 changes: 53 additions & 7 deletions src/ops/builtins.c
Original file line number Diff line number Diff line change
Expand Up @@ -2080,21 +2080,55 @@ ray_t* ray_read_bytes_fn(ray_t* path_obj) {
return read_file_bytes(path_obj, "read-bytes");
}

/* (load path) — read and evaluate a Rayfall script file via mmap */
static bool load_path_is_absolute(const char* p) {
if (p[0] == '/' || p[0] == '\\') return true;
#if defined(RAY_OS_WINDOWS)
if (((p[0] >= 'A' && p[0] <= 'Z') || (p[0] >= 'a' && p[0] <= 'z')) && p[1] == ':') return true;
#endif
return false;
}

/* Where a relative load path is looked for after the working directory:
* below $RAYFORCE_HOME, q's QHOME fallback (#506). Fills `alt` with the
* candidate and returns true when a home is set and the path is relative. */
static bool load_home_candidate(const char* path, char* alt, size_t cap) {
if (load_path_is_absolute(path)) return false;
const char* home = getenv("RAYFORCE_HOME");
if (!home || !*home) return false;
size_t hl = strlen(home);
while (hl > 1 && (home[hl - 1] == '/' || home[hl - 1] == '\\')) hl--;
int n = snprintf(alt, cap, "%.*s/%s", (int)hl, home, path);
return n > 0 && (size_t)n < cap;
}

/* (load path) — read and evaluate a Rayfall script file via mmap.
*
* A relative path is resolved against the working directory first and,
* when that does not exist and RAYFORCE_HOME is set, below the home;
* an absolute path is used as given. Nested loads follow the same rule
* (relative to the working directory, not to the loading file, as q
* does). The path that was actually opened is what (.sys.args)
* reports as `source` while the file runs. */
ray_t* ray_load_file_fn(ray_t* path_obj) {
if (path_obj->type != -RAY_STR) return ray_error("type", "load: path must be str, got %s", ray_type_name(path_obj->type));
const char* path = ray_str_ptr(path_obj);
if (!path) return ray_error("domain", "load: empty path");
size_t path_len = ray_str_len(path_obj);
char alt[4096];

#if defined(RAY_OS_WINDOWS)
/* Windows: fall back to fread */
FILE* fp = fopen(path, "r");
if (!fp) return ray_error("io", NULL);
if (!fp && errno == ENOENT && load_home_candidate(path, alt, sizeof(alt))) {
fp = fopen(alt, "r");
if (!fp) return ray_error("io", "load \"%s\": %s (also tried \"%s\")", path, strerror(ENOENT), alt);
path = alt; path_len = strlen(alt);
}
if (!fp) return ray_error("io", "load \"%s\": %s", path, strerror(errno));
fseek(fp, 0, SEEK_END);
long sz = ftell(fp);
fseek(fp, 0, SEEK_SET);
if (sz < 0) { fclose(fp); return ray_error("io", NULL); }
if (sz < 0) { int e = errno; fclose(fp); return ray_error("io", "load \"%s\": cannot determine size: %s", path, strerror(e)); }
if (sz == 0) { fclose(fp); return ray_i64(0); }
char* buf = (char*)ray_alloc_raw((size_t)sz + 1);
if (!buf) { fclose(fp); return ray_error("oom", NULL); }
Expand All @@ -2116,15 +2150,27 @@ ray_t* ray_load_file_fn(ray_t* path_obj) {
ray_free_raw(buf);
return result;
#else
/* Every failure names the file and the OS cause: a bare `io` from a
* script's load is indistinguishable from an IPC, journal or storage
* failure and sends diagnosis the wrong way (#505). */
int fd = open(path, O_RDONLY);
if (fd < 0) return ray_error("io", NULL);
if (fd < 0 && errno == ENOENT && load_home_candidate(path, alt, sizeof(alt))) {
fd = open(alt, O_RDONLY);
if (fd < 0) return ray_error("io", "load \"%s\": %s (also tried \"%s\")", path, strerror(ENOENT), alt);
path = alt; path_len = strlen(alt);
}
if (fd < 0) return ray_error("io", "load \"%s\": %s", path, strerror(errno));
struct stat st;
if (fstat(fd, &st) < 0 || st.st_size < 0) { close(fd); return ray_error("io", NULL); }
if (fstat(fd, &st) < 0 || st.st_size < 0) {
int e = errno; close(fd);
return ray_error("io", "load \"%s\": cannot stat: %s", path, strerror(e));
}
if (S_ISDIR(st.st_mode)) { close(fd); return ray_error("io", "load \"%s\": is a directory", path); }
size_t sz = (size_t)st.st_size;
if (sz == 0) { close(fd); return ray_i64(0); }
char* map = (char*)ray_vm_map_fd_ro(fd, sz);
close(fd);
if (!map) return ray_error("io", NULL);
{ int e = errno; close(fd); errno = e; }
if (!map) return ray_error("io", "load \"%s\": cannot map %zu bytes: %s", path, sz, strerror(errno));
/* Copy to NUL-terminated buffer -- mmap region may not have a trailing NUL */
char* buf = (char*)ray_alloc_raw(sz + 1);
if (!buf) { ray_vm_unmap_file(map, sz); return ray_error("oom", NULL); }
Expand Down
30 changes: 28 additions & 2 deletions src/ops/system.c
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
#include "lang/internal.h"
#include "lang/env.h"
#include "lang/eval.h" /* LAMBDA_PARAMS */
#include "lang/nfo.h" /* NFO_FILENAME */
#include "lang/parse.h"
#include "ops/ops.h" /* ray_is_lazy, ray_lazy_materialize */
#include "ops/internal.h" /* ray_group_perpart_runs — (.sys.mem) counter */
Expand Down Expand Up @@ -1372,12 +1373,37 @@ ray_t* ray_build_sys_args(int argc, char** argv) {
}

/* (.sys.args) -- return the application-arguments dict (empty if unset) */
/* The launcher dict is built once at startup; `source` is the one key
* that changes while the process runs — the file currently being
* evaluated (the innermost `load`, or the command-line script), by the
* path that was actually opened, so a script can locate its neighbours
* from any working directory (#506). Empty at the REPL, under a pipe,
* or in a hook or timer outside any file — the same convention as
* `file`. bash's $BASH_SOURCE next to $0. */
ray_t* ray_sys_args_fn(ray_t** args, int64_t n) {
(void)args;
if (n != 0) return ray_error("domain", ".sys.args takes no arguments");
ray_t* d = (ray_t*)ray_runtime_get_sys_args();
if (d) { ray_retain(d); return d; }
return ray_dict_new(ray_sym_vec_new(RAY_SYM_W64, 0), ray_list_new(0));
ray_t* keys; ray_t* vals;
if (d) {
keys = ray_dict_keys(d); ray_retain(keys); /* append COWs the shared vectors */
vals = ray_dict_vals(d); ray_retain(vals);
} else {
keys = ray_sym_vec_new(RAY_SYM_W64, 1);
vals = ray_list_new(1);
}
ray_t* nfo = ray_eval_get_nfo();
ray_t* src = (nfo && !RAY_IS_ERR(nfo)) ? NFO_FILENAME(nfo) : NULL;
bool is_file = src && !RAY_IS_ERR(src) && src->type == -RAY_STR &&
!(ray_str_len(src) == strlen(RAY_NFO_REPL_NAME) &&
memcmp(ray_str_ptr(src), RAY_NFO_REPL_NAME, ray_str_len(src)) == 0);
ray_t* sv = is_file ? (ray_retain(src), src) : ray_str("", 0);
int64_t k = ray_sym_intern("source", 6);
keys = ray_vec_append(keys, &k);
if (RAY_IS_ERR(keys)) { ray_release(vals); ray_release(sv); return keys; }
vals = ray_list_append(vals, sv); ray_release(sv);
if (RAY_IS_ERR(vals)) { ray_release(keys); return vals; }
return ray_dict_new(keys, vals);
}

/* ══════════════════════════════════════════
Expand Down
15 changes: 15 additions & 0 deletions test/rfl/system/load_errors.rfl
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
;; load_errors.rfl — a failing `load` names the file and the OS cause (#505).
;;
;; Regression: ray_load_file_fn returned a bare `io` for every failure, so
;; a missing dependency in a startup script printed `error: io`, which reads
;; like an IPC, journal or storage failure and sent diagnosis the wrong way.
;; The harness compares error codes only, so the message is checked on a
;; subprocess's stderr.
(load "missing/persistent_connection.rfl") !- io
(load "/") !- io
(.sys.exec "rm -rf /tmp/rfl_load_err && mkdir -p /tmp/rfl_load_err && printf '(load \"missing/persistent_connection.rfl\")\n' > /tmp/rfl_load_err/main.rfl")
(.sys.exec "./rayforce /tmp/rfl_load_err/main.rfl </dev/null 2>&1 | grep -q 'error: io: load \"missing/persistent_connection.rfl\": No such file or directory'") -- 0
(.sys.exec "printf '(load \"/tmp/rfl_load_err\")\n' > /tmp/rfl_load_err/dir.rfl; ./rayforce /tmp/rfl_load_err/dir.rfl </dev/null 2>&1 | grep -q 'error: io: load \"/tmp/rfl_load_err\": is a directory'") -- 0
;; a script that fails to load still exits non-zero
(.sys.exec "./rayforce /tmp/rfl_load_err/main.rfl </dev/null >/dev/null 2>&1; [ $? -ne 0 ]") -- 0
(.sys.exec "rm -rf /tmp/rfl_load_err")
40 changes: 40 additions & 0 deletions test/rfl/system/load_home.rfl
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
;; load_home.rfl — relative `load` paths fall back to RAYFORCE_HOME, and
;; (.sys.args) reports the file being evaluated as `source` (#506).
;;
;; Layout: a project at /tmp/rfl_lh/proj and a home at /tmp/rfl_lh/home.
;; proj/main.rfl loads "lib/a.rfl" (only in home) and "lib/b.rfl"
;; (in both: the working directory must win)
;; home/lib/a.rfl records its own `source`, then loads "lib/c.rfl"
;; home/lib/c.rfl records its `source`
;; proj/lib/b.rfl sets b_from 'proj; home/lib/b.rfl sets b_from 'home
;; Each case runs a subprocess from the project directory.
(.sys.exec "rm -rf /tmp/rfl_lh && mkdir -p /tmp/rfl_lh/proj/lib /tmp/rfl_lh/home/lib")
(.sys.exec "printf '%s\n' '(set a_src (at (.sys.args) (quote source)))' '(load \"lib/c.rfl\")' '(set a_src_after (at (.sys.args) (quote source)))' > /tmp/rfl_lh/home/lib/a.rfl")
(.sys.exec "printf '%s\n' '(set c_src (at (.sys.args) (quote source)))' > /tmp/rfl_lh/home/lib/c.rfl")
(.sys.exec "printf '%s\n' '(set b_from (quote home))' > /tmp/rfl_lh/home/lib/b.rfl")
(.sys.exec "printf '%s\n' '(set b_from (quote proj))' > /tmp/rfl_lh/proj/lib/b.rfl")
(.sys.exec "printf '%s\n' '(load \"lib/a.rfl\")' '(load \"lib/b.rfl\")' '(println (str-join (list a_src c_src a_src_after (as (quote str) b_from) (at (.sys.args) (quote source)) (at (.sys.args) (quote file))) \"|\"))' > /tmp/rfl_lh/proj/main.rfl")

;; home fallback, nested load, source restored after a nested load,
;; working directory wins for b, top-level source == file
(.sys.exec "cd /tmp/rfl_lh/proj && RAYFORCE_HOME=/tmp/rfl_lh/home $OLDPWD/rayforce main.rfl </dev/null 2>&1 | grep -qx '/tmp/rfl_lh/home/lib/a.rfl|/tmp/rfl_lh/home/lib/c.rfl|/tmp/rfl_lh/home/lib/a.rfl|proj|main.rfl|main.rfl'") -- 0

;; a trailing slash on the home is fine
(.sys.exec "cd /tmp/rfl_lh/proj && RAYFORCE_HOME=/tmp/rfl_lh/home/ $OLDPWD/rayforce main.rfl </dev/null 2>&1 | grep -q '^/tmp/rfl_lh/home/lib/a.rfl|'") -- 0

;; without a home the same script fails, naming only the working-directory path
(.sys.exec "cd /tmp/rfl_lh/proj && $OLDPWD/rayforce main.rfl </dev/null 2>&1 | grep -q 'error: io: load \"lib/a.rfl\": No such file or directory$'") -- 0

;; missing in both places: both attempts are named
(.sys.exec "printf '(load \"lib/zz.rfl\")\n' > /tmp/rfl_lh/proj/miss.rfl; cd /tmp/rfl_lh/proj && RAYFORCE_HOME=/tmp/rfl_lh/home $OLDPWD/rayforce miss.rfl </dev/null 2>&1 | grep -q 'error: io: load \"lib/zz.rfl\": No such file or directory (also tried \"/tmp/rfl_lh/home/lib/zz.rfl\")'") -- 0

;; an absolute path never consults the home
(.sys.exec "printf '(load \"/tmp/rfl_lh/nowhere.rfl\")\n' > /tmp/rfl_lh/proj/abs.rfl; cd /tmp/rfl_lh/proj && RAYFORCE_HOME=/tmp/rfl_lh/home $OLDPWD/rayforce abs.rfl </dev/null 2>&1 | grep -q 'error: io: load \"/tmp/rfl_lh/nowhere.rfl\": No such file or directory$'") -- 0

;; under a pipe there is no file: source is empty, and so is file
(.sys.exec "printf '(println (count (at (.sys.args) (quote source))))\n' | ./rayforce -i 2>&1 | grep -qx 0") -- 0

;; in-process: this harness runs each .rfl through eval, not load, so
;; source is whatever the harness set — only the key's presence is pinned
(type (at (.sys.args) 'source)) -- 'str
(.sys.exec "rm -rf /tmp/rfl_lh")
12 changes: 9 additions & 3 deletions test/test_runtime.c
Original file line number Diff line number Diff line change
Expand Up @@ -595,15 +595,21 @@ static test_result_t test_build_sys_args_edges(void) {
PASS();
}

/* .sys.args builtin: empty when unset; reflects stored dict when set */
/* .sys.args builtin: only `source` when unset; reflects stored dict when set */
static test_result_t test_sys_args_builtin(void) {
/* unset → empty dict, never NULL */
/* unset → a dict holding just `source` (empty here: ray_eval_str is
* no file, #506), never NULL */
ray_t* e = ray_eval_str("(.sys.args)");
TEST_ASSERT_NOT_NULL(e);
TEST_ASSERT_FALSE(RAY_IS_ERR(e));
TEST_ASSERT_EQ_I(e->type, RAY_DICT);
TEST_ASSERT_EQ_I(ray_dict_len(e), 0);
TEST_ASSERT_EQ_I(ray_dict_len(e), 1);
ray_release(e);
ray_t* src = ray_eval_str("(count (at (.sys.args) 'source))");
TEST_ASSERT_NOT_NULL(src);
TEST_ASSERT_FALSE(RAY_IS_ERR(src));
TEST_ASSERT_EQ_I(src->i64, 0);
ray_release(src);

/* set, then read back through the builtin */
char* argv[] = { "rayforce", "-p", "5000", "--", "-opt", "123" };
Expand Down
Loading