Skip to content

Commit 42c6fdf

Browse files
BuddhiLWchaploud
authored andcommitted
fix(nrepl): resolve the filesystem classpath for nREPL sessions (D-322)
Smell-audited: 1: the `-cp`-wins-else-$CLJW_PATH-else-"." rule was about to be spelled a third time in the nrepl branch — folded into one `resolveClasspath` instead, with `resolveDefaultClasspath` as its no-flags call. `cljw nrepl` rejected `-cp`, ignored $CLJW_PATH, and never called require_resolver.installChained, so a connected editor could evaluate `(+ 1 2)` but could not `(require '[my.lib])` from the project it was started in. D-322 landed the filesystem require chain on repl.run; the nREPL server was not carried along, which left the one entry point where the user is certain to be on a project as the only one without a classpath. The nrepl branch now parses `[-cp <dirs>] [-A:alias…]` alongside `--port` and threads `load_paths` + `fs_jail_root` into nrepl.run, which installs them exactly as repl.zig does. test/e2e/phase14_nrepl_classpath.sh covers one case per rung of the ADR-0084 rule (`-cp`, $CLJW_PATH, ./deps.edn :paths off the cwd) plus a negative case pinning that the arg loop still rejects unknown flags.
1 parent fcb8608 commit 42c6fdf

4 files changed

Lines changed: 221 additions & 13 deletions

File tree

‎src/app/cli.zig‎

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,14 @@ pub fn dispatch(init: std.process.Init) !void {
137137
return;
138138
}
139139
if (std.mem.eql(u8, first, "nrepl")) {
140-
// nREPL server (ADR-0048). Optional `--port N`
141-
// (default 7888 per JVM nREPL convention).
140+
// nREPL server (ADR-0048). Optional `--port N` (default 7888 per
141+
// JVM nREPL convention), plus the same `[-cp <dirs>] [-A:alias…]`
142+
// classpath surface every other entry point carries (D-322): an
143+
// editor session is the one place the user is CERTAIN to be on a
144+
// project, so `(require '[my.lib])` must resolve off disk there.
142145
var port: u16 = 7888;
146+
var nrepl_cp: ?[]const u8 = null;
147+
var nrepl_aliases: std.ArrayList([]const u8) = .empty;
143148
while (args.next()) |a| {
144149
if (std.mem.eql(u8, a, "--port")) {
145150
const p_str = args.next() orelse {
@@ -152,13 +157,25 @@ pub fn dispatch(init: std.process.Init) !void {
152157
try stderr.flush();
153158
std.process.exit(1);
154159
};
160+
} else if (std.mem.eql(u8, a, "-cp") or std.mem.eql(u8, a, "--classpath")) {
161+
nrepl_cp = args.next() orelse {
162+
try stderr.print("nrepl: -cp requires an argument\n", .{});
163+
try stderr.flush();
164+
std.process.exit(1);
165+
};
166+
} else if (std.mem.startsWith(u8, a, "-A")) {
167+
var it = std.mem.splitScalar(u8, a[2..], ':');
168+
while (it.next()) |name| {
169+
if (name.len > 0) try nrepl_aliases.append(arena, name);
170+
}
155171
} else {
156172
try stderr.print("nrepl: unknown argument '{s}'\n", .{a});
157173
try stderr.flush();
158174
std.process.exit(1);
159175
}
160176
}
161-
return nrepl.run(io, gpa, arena, stdout, stderr, port);
177+
const load_paths = try resolveClasspath(io, arena, stderr, nrepl_cp, cljw_path, nrepl_aliases.items, git_cache_base);
178+
return nrepl.run(io, gpa, arena, stdout, stderr, port, load_paths, fs_jail_root);
162179
}
163180
if (std.mem.eql(u8, first, "build")) {
164181
// D-100(b) + ADR-0034 am3/am4: `cljw build <in.clj> -o <out>` (script
@@ -339,8 +356,10 @@ fn dispatchArgsRest(
339356
\\
340357
\\Subcommands:
341358
\\ repl Start a terminal REPL.
342-
\\ nrepl [--port N] Start an nREPL server (default port 7888) for
343-
\\ CIDER and other editors.
359+
\\ nrepl [--port N] [-cp <dirs>] [-A:alias…]
360+
\\ Start an nREPL server (default port 7888) for
361+
\\ CIDER and other editors. Classpath resolves as
362+
\\ for a run: `-cp` wins, else $CLJW_PATH, else ".".
344363
\\ build <in.clj> -o <out> [-cp <dirs>] [-A:alias…]
345364
\\ Compile a script (with the runtime) into one
346365
\\ self-contained native binary. Main-mode variant:
@@ -495,17 +514,30 @@ fn loadDepsEdn(io: std.Io, arena: std.mem.Allocator, stderr: *std.Io.Writer, bas
495514
return .{ .load_paths = try merged.toOwnedSlice(arena), .cfg = cfg };
496515
}
497516

498-
/// The default classpath for a no-`-cp` REPL entry (the `repl` subcommand + the
499-
/// bare no-args fallback): `$CLJW_PATH` (else cwd ".") split into roots, with a
500-
/// `./deps.edn` `:paths`/source-deps prepended. Mirrors `dispatchArgsRest`'s
501-
/// resolution minus the `-cp` flag + `-A` aliases, so the classpath-resolution
502-
/// behaviour lives in one place rather than duplicated per REPL entry (F-011).
503-
fn resolveDefaultClasspath(io: std.Io, arena: std.mem.Allocator, stderr: *std.Io.Writer, cljw_path_env: ?[]const u8, git_cache_base: ?[]const u8) ![]const []const u8 {
504-
const base_paths = try splitClasspath(arena, cljw_path_env orelse ".");
505-
const deps = try loadDepsEdn(io, arena, stderr, base_paths, &.{}, git_cache_base);
517+
/// The classpath for a REPL-shaped entry (`repl`, `nrepl`, the bare no-args
518+
/// fallback), resolved by the ADR-0084 rule: `-cp` wins, else `$CLJW_PATH`,
519+
/// else cwd ".", split into roots with a `./deps.edn` `:paths`/source-deps
520+
/// prepended. Mirrors `dispatchArgsRest`'s resolution so the rule lives in one
521+
/// place rather than duplicated per entry point (F-011).
522+
fn resolveClasspath(
523+
io: std.Io,
524+
arena: std.mem.Allocator,
525+
stderr: *std.Io.Writer,
526+
classpath_arg: ?[]const u8,
527+
cljw_path_env: ?[]const u8,
528+
alias_names: []const []const u8,
529+
git_cache_base: ?[]const u8,
530+
) ![]const []const u8 {
531+
const base_paths = try splitClasspath(arena, classpath_arg orelse cljw_path_env orelse ".");
532+
const deps = try loadDepsEdn(io, arena, stderr, base_paths, alias_names, git_cache_base);
506533
return deps.load_paths;
507534
}
508535

536+
/// `resolveClasspath` for an entry that takes neither `-cp` nor `-A`.
537+
fn resolveDefaultClasspath(io: std.Io, arena: std.mem.Allocator, stderr: *std.Io.Writer, cljw_path_env: ?[]const u8, git_cache_base: ?[]const u8) ![]const []const u8 {
538+
return resolveClasspath(io, arena, stderr, null, cljw_path_env, &.{}, git_cache_base);
539+
}
540+
509541
/// Split a colon-separated classpath string into its directory roots, allocated
510542
/// in `arena`. Empty segments are dropped; an all-empty spec yields `["."]`.
511543
fn splitClasspath(arena: std.mem.Allocator, spec: []const u8) ![]const []const u8 {

‎src/app/nrepl.zig‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const std = @import("std");
2828
const Writer = std.Io.Writer;
2929

3030
const bootstrap = @import("../lang/bootstrap.zig");
31+
const require_resolver = @import("../lang/require_resolver.zig");
3132
const macro_dispatch = @import("../eval/macro_dispatch.zig");
3233
const driver = @import("../eval/driver.zig");
3334
const Runtime = @import("../runtime/runtime.zig").Runtime;
@@ -46,6 +47,13 @@ pub fn run(
4647
stdout: *Writer,
4748
stderr: *Writer,
4849
port: u16,
50+
/// Filesystem classpath the sessions' `(require …)` searches, so an editor
51+
/// resolves project libs off disk exactly as a file/`-e` run does (D-322).
52+
/// Mirrors `repl.run`.
53+
load_paths: []const []const u8,
54+
/// Deploy-mode FS jail root (`CLJW_FS_ROOT`), or null for an unconfined
55+
/// local server (ADR-0123 / SE-6/7).
56+
fs_jail_root: ?[]const u8,
4957
) !void {
5058
// Address + bind. IpAddress.parseIp4 + listen are the Zig 0.16
5159
// canonical sync server pattern (cf. lib/std/Io/net.zig:246).
@@ -91,6 +99,13 @@ pub fn run(
9199
return err;
92100
};
93101

102+
// ADR-0084 / D-322: enable filesystem `require` for user libs, exactly as
103+
// repl.zig does — setupCore* installs the embedded-only resolver, so swap
104+
// to the embedded-FIRST chain + the classpath.
105+
rt.load_paths = load_paths;
106+
rt.fs_jail_root = fs_jail_root;
107+
require_resolver.installChained(&rt);
108+
94109
var registry = session_mod.Registry.init(gpa, &rt.gc, io);
95110
defer registry.deinit();
96111

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
#!/usr/bin/env bash
2+
# test/e2e/phase14_nrepl_classpath.sh
3+
#
4+
# D-322 for the nREPL server: a session must resolve `(require '[my.lib])`
5+
# off the filesystem classpath, exactly as `cljw <file>` / `cljw -e` /
6+
# `cljw repl` do. Before this, `cljw nrepl` took no `-cp`, ignored
7+
# $CLJW_PATH, and never called require_resolver.installChained — so an
8+
# editor could evaluate arithmetic but could not load one line of the
9+
# project it was started in.
10+
#
11+
# Three cases, one per rung of the ADR-0084 resolution rule
12+
# (`-cp` wins, else $CLJW_PATH, else "." + ./deps.edn :paths):
13+
# 1. -cp <dir>
14+
# 2. $CLJW_PATH
15+
# 3. neither — ./deps.edn :paths off the cwd
16+
# plus a negative case pinning that unknown args are still rejected.
17+
#
18+
# Same harness shape as phase14_nrepl.sh: launch the server on a random
19+
# high port, drive bencode over a socket from python3, assert the
20+
# `value` frame.
21+
22+
set -euo pipefail
23+
cd "$(dirname "$0")/../.."
24+
ROOT=$(pwd)
25+
26+
BIN="$ROOT/zig-out/bin/cljw"
27+
[ -n "${CLJW_SKIP_BUILD:-}" ] || zig build -Dwasm -Doptimize="${CLJW_OPT:-ReleaseSafe}" >/dev/null
28+
29+
WORK=$(mktemp -d)
30+
SERVER_PID=""
31+
cleanup() {
32+
[[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null || true
33+
rm -rf "$WORK"
34+
}
35+
trap cleanup EXIT
36+
37+
fail() { echo "FAIL $1" >&2; exit 1; }
38+
39+
# A throwaway project: one namespace under src/, declared in deps.edn.
40+
mkdir -p "$WORK/src/cljw_cp_probe"
41+
cat > "$WORK/src/cljw_cp_probe/lib.clj" <<'EOF'
42+
(ns cljw-cp-probe.lib)
43+
(defn answer [] 42)
44+
EOF
45+
cat > "$WORK/deps.edn" <<'EOF'
46+
{:paths ["src"]}
47+
EOF
48+
49+
# Drive one eval over bencode; echoes each `value` frame on its own line.
50+
drive() {
51+
python3 - "$1" "$2" <<'PY'
52+
import socket, sys, time
53+
54+
def encode(v):
55+
if isinstance(v, int): return f"i{v}e".encode()
56+
if isinstance(v, (bytes, str)):
57+
b = v.encode() if isinstance(v, str) else v
58+
return f"{len(b)}:".encode() + b
59+
if isinstance(v, list): return b"l" + b"".join(encode(x) for x in v) + b"e"
60+
if isinstance(v, dict):
61+
out = b"d"
62+
for k in sorted(v.keys()):
63+
out += encode(k) + encode(v[k])
64+
return out + b"e"
65+
raise ValueError(type(v))
66+
67+
def decode(buf, i=0):
68+
c = buf[i:i+1]
69+
if c == b"i":
70+
j = buf.index(b"e", i); return int(buf[i+1:j]), j+1
71+
if c.isdigit():
72+
j = buf.index(b":", i); n = int(buf[i:j]); return buf[j+1:j+1+n], j+1+n
73+
if c == b"l":
74+
i += 1; out = []
75+
while buf[i:i+1] != b"e":
76+
v, i = decode(buf, i); out.append(v)
77+
return out, i+1
78+
if c == b"d":
79+
i += 1; out = {}
80+
while buf[i:i+1] != b"e":
81+
k, i = decode(buf, i); v, i = decode(buf, i); out[k.decode()] = v
82+
return out, i+1
83+
raise ValueError(buf[i:i+4])
84+
85+
port, code = int(sys.argv[1]), sys.argv[2]
86+
s = socket.create_connection(("127.0.0.1", port), timeout=5)
87+
s.settimeout(5)
88+
s.sendall(encode({"op": "eval", "code": code, "id": "1"}))
89+
buf, t0 = b"", time.time()
90+
while time.time() - t0 < 5:
91+
try:
92+
chunk = s.recv(4096)
93+
except socket.timeout:
94+
break
95+
if not chunk: break
96+
buf += chunk
97+
if b"done" in buf: break
98+
i = 0
99+
while i < len(buf):
100+
try:
101+
m, i = decode(buf, i)
102+
except Exception:
103+
break
104+
if "value" in m: print(m["value"].decode())
105+
if "err" in m: print("ERR " + m["err"].decode().replace("\n", " "))
106+
PY
107+
}
108+
109+
# Start a server in $WORK and wait for its port file. $1 = port, rest = argv.
110+
start_server() {
111+
local port="$1"; shift
112+
rm -f "$WORK/.nrepl-port"
113+
( cd "$WORK" && "$BIN" nrepl --port "$port" "$@" >"$WORK/out.log" 2>&1 ) &
114+
SERVER_PID=$!
115+
local deadline=$((SECONDS + 10))
116+
while [[ ! -f "$WORK/.nrepl-port" ]] && [[ $SECONDS -lt $deadline ]]; do sleep 0.1; done
117+
[[ -f "$WORK/.nrepl-port" ]] || fail "server did not bind within 10s: $(cat "$WORK/out.log")"
118+
}
119+
120+
stop_server() {
121+
kill "$SERVER_PID" 2>/dev/null || true
122+
wait "$SERVER_PID" 2>/dev/null || true
123+
SERVER_PID=""
124+
sleep 0.2
125+
}
126+
127+
CODE='(require (quote [cljw-cp-probe.lib :as p])) (p/answer)'
128+
129+
# --- Case 1: -cp <dir> ---
130+
PORT=$(( 19100 + (RANDOM % 300) ))
131+
start_server "$PORT" -cp src
132+
got=$(drive "$PORT" "$CODE" | tail -1)
133+
stop_server
134+
[[ "$got" == "42" ]] || fail "nrepl_cp_flag: expected 42, got '$got'"
135+
echo "PASS nrepl_cp_flag -> 42"
136+
137+
# --- Case 2: $CLJW_PATH ---
138+
PORT=$(( 19400 + (RANDOM % 300) ))
139+
CLJW_PATH=src start_server "$PORT"
140+
got=$(drive "$PORT" "$CODE" | tail -1)
141+
stop_server
142+
[[ "$got" == "42" ]] || fail "nrepl_cljw_path_env: expected 42, got '$got'"
143+
echo "PASS nrepl_cljw_path_env -> 42"
144+
145+
# --- Case 3: neither — ./deps.edn :paths off the cwd ---
146+
PORT=$(( 19700 + (RANDOM % 300) ))
147+
start_server "$PORT"
148+
got=$(drive "$PORT" "$CODE" | tail -1)
149+
stop_server
150+
[[ "$got" == "42" ]] || fail "nrepl_deps_edn_paths: expected 42, got '$got'"
151+
echo "PASS nrepl_deps_edn_paths -> 42"
152+
153+
# --- Case 4: unknown args are still rejected (the parse loop stays closed) ---
154+
if "$BIN" nrepl --bogus >"$WORK/bogus.log" 2>&1; then
155+
fail "nrepl_unknown_arg: --bogus was accepted"
156+
fi
157+
grep -q "unknown argument" "$WORK/bogus.log" || fail "nrepl_unknown_arg: no diagnostic: $(cat "$WORK/bogus.log")"
158+
echo "PASS nrepl_unknown_arg -> rejected"
159+
160+
echo "ALL PASS test/e2e/phase14_nrepl_classpath.sh"

‎test/run_all.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,7 @@ run_step "e2e_phase14_future_promise_delay" "bash test/e2e/phase14_future_promi
533533
run_step "e2e_phase14_repl" "bash test/e2e/phase14_repl.sh"
534534
run_step "e2e_phase14_nrepl" "bash test/e2e/phase14_nrepl.sh"
535535
run_step "e2e_phase14_nrepl_completion" "bash test/e2e/phase14_nrepl_completion.sh"
536+
run_step "e2e_phase14_nrepl_classpath" "bash test/e2e/phase14_nrepl_classpath.sh"
536537
run_step "e2e_phase14_error_format" "bash test/e2e/phase14_error_format.sh"
537538
run_step "e2e_phase14_render_error" "bash test/e2e/phase14_render_error.sh"
538539
run_step "e2e_phase14_callable_print" "bash test/e2e/phase14_callable_print.sh"

0 commit comments

Comments
 (0)