From d9711f82bc549212559576ed17aae6a8053a8e68 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Thu, 10 Sep 2026 22:50:50 +0700 Subject: [PATCH] A form from a package file means what the import made it mean C-c C-c on settle inside sand-sim/sim.flan declared settle, but the running program only ever knew it as sim/settle. The form spliced as a brand-new unrelated name, the evaluation answered ok, and nothing changed. Sand's simulation lives in a package, so the one thing worth tuning live was the one thing that silently did nothing - and reported success while doing it. Load now records what alias each package directory was imported under and what names it owns, because a file on disk does not say what it is called from outside; the importer chooses that. A session looks the editing file's directory up in that table and qualifies the incoming forms through Load's own qualify_decl, so a redefined settle lands on sim/settle and its call to move-grain lands on sim/move-grain, by the same rule the import used. A name the package does not own - the prelude's - is left alone. Derived from the path rather than sent by the editor, which is where this departs from CIDER's ns key: a Clojure namespace is declared in the file, but a Flan alias is not written anywhere the editor can see it. One directory imported under two aliases is refused with the reason instead of resolved to either. --- NEXT.md | 15 ++++++++++++ lib/load.ml | 16 ++++++++++--- lib/session.ml | 57 ++++++++++++++++++++++++++++++++++++++++++-- test/test_session.ml | 25 +++++++++++++++++++ 4 files changed, 108 insertions(+), 5 deletions(-) diff --git a/NEXT.md b/NEXT.md index f859017..95694dc 100644 --- a/NEXT.md +++ b/NEXT.md @@ -573,6 +573,21 @@ Two things the session knows that no single evaluation could: the program uses, which is exactly where the silent version lives. The fixtures carry an unused `defvar` and a C-called `defn` for that reason. +**A form typed into a file that is imported as a package is qualified the way +the import qualified it.** `settle` in `sand-sim/sim.flan` becomes `sim/settle`, +and its call to `move-grain` becomes `sim/move-grain` — through `Load`'s own +`qualify_decl`, so the rule cannot drift from the one used at import time. +Without this the form spliced as a brand-new unrelated name: the evaluation +answered `ok`, and the running program went on calling the `sim/settle` it +already had. Since sand's simulation lives in a package, the one thing worth +tuning live was the one thing that silently did nothing. + +It is derived from the file's path and **not sent by the editor**, which is +where this departs from CIDER's `ns` key: a Clojure namespace is declared in +the file, but a Flan alias is chosen by whatever imported the directory and is +written nowhere the editor can see. One directory imported under two aliases is +refused with the reason rather than resolved to either. + The accumulated list is the **post-`Load`** one, so an evaluated `(import …)` is spliced as its expansion. Otherwise re-evaluating a file that imports something appends a second import, `Load` expands it again, and the diff --git a/lib/load.ml b/lib/load.ml index 5c86674..89635b5 100644 --- a/lib/load.ml +++ b/lib/load.ml @@ -30,8 +30,16 @@ type t = { decls : Ast.decl list; csrcs : string list; (* C sources compiled into the build *) lflags : string list; (* extra linker arguments *) + (* Which alias each package's directory was imported under, and the names it + owns. A file on disk does not say what it is called from outside — the + *importer* chooses that — so this is the only place the answer exists, and + a REPL editing a package's source needs it to know that [settle] typed in + sand-sim/sim.flan means [sim/settle] to the running program. *) + pkgs : pkg list; } +and pkg = { alias : string; dir : string; owns : string list } + let fail loc fmt = Printf.ksprintf (fun m -> raise (Loc.Error (loc, m))) fmt (* "vendor:raylib" -> the collection "vendor" and the subpath "raylib". A path @@ -252,7 +260,8 @@ let import ~loc alias dir = close_in ch; r end in - { decls; csrcs = entries dir ".c"; lflags } + { decls; csrcs = entries dir ".c"; lflags; + pkgs = [ { alias; dir; owns = owned } ] } (* ── The one entry point ───────────────────────────────────────────── *) @@ -265,7 +274,8 @@ let program ~file (decls : Ast.decl list) : t = let p = import ~loc:d.Ast.dloc alias dir in { decls = acc.decls @ p.decls; csrcs = acc.csrcs @ p.csrcs; - lflags = acc.lflags @ p.lflags } + lflags = acc.lflags @ p.lflags; + pkgs = acc.pkgs @ p.pkgs } | _ -> { acc with decls = acc.decls @ [ d ] }) - { decls = []; csrcs = []; lflags = [] } + { decls = []; csrcs = []; lflags = []; pkgs = [] } decls diff --git a/lib/session.ml b/lib/session.ml index 5a344a8..03b16d7 100644 --- a/lib/session.ml +++ b/lib/session.ml @@ -33,6 +33,7 @@ type t = { mutable decls : Ast.decl list; (* post-Load: flat, one namespace *) mutable program : Tast.program; (* the last thing that checked *) host : Tast.program; (* what the process was built from *) + pkgs : Load.pkg list; (* alias, directory, names owned *) } let fail = Loc.fail @@ -59,7 +60,44 @@ let rec same_const (a : Tast.expr) (b : Tast.expr) = let create ~file = let l = Load.program ~file (Parse.program (Reader.read_file file)) in let p = Check.program l.Load.decls in - ({ file; decls = l.Load.decls; program = p; host = p }, l) + ({ file; decls = l.Load.decls; program = p; host = p; pkgs = l.Load.pkgs }, l) + +(* Which package a file being edited belongs to, if any. + + A form typed into sand-sim/sim.flan declares [settle], but the running + program only ever knew it as [sim/settle]: the alias is chosen by whatever + imported the directory, and is written nowhere in the file itself. Without + this the form splices as a brand-new unrelated name, the evaluation reports + success, and nothing changes — the exact failure this whole design is meant + to make impossible. + + Derived from the path rather than sent by the editor for that same reason: + the editor cannot know an alias the file does not mention. *) +let package_of t origin = + match origin with + | "" -> None + | origin -> + let dir = + try Filename.dirname (Unix.realpath origin) + with Unix.Unix_error _ -> Filename.dirname origin + in + let same p = + let d = + try Unix.realpath p.Load.dir with Unix.Unix_error _ -> p.Load.dir + in + String.equal d dir + in + (match List.filter same t.pkgs with + | [] -> None + | [ p ] -> Some p + (* One directory under two aliases: both are live in the program and a + form cannot mean both. Say so rather than picking one. *) + | ps -> + Loc.fail Loc.unknown + "%s is imported under more than one alias (%s); a form here would \ + have to mean all of them" + dir + (String.concat ", " (List.map (fun p -> p.Load.alias) ps))) (* A name the running process exports. Everything else is looked up by name at install time — see [Emit.redefinition]'s [known]. *) @@ -207,7 +245,22 @@ let eval ?(origin = "") t src : change = why the accumulated list is the post-Load one: re-evaluating a file that imports something would otherwise append a second copy of the import and the duplicate-name pass would reject it. *) - let incoming = (Load.program ~file:t.file (Parse.program forms)).Load.decls in + let incoming = + let ds = (Load.program ~file:t.file (Parse.program forms)).Load.decls in + match package_of t origin with + | None -> ds + | Some p -> + (* Qualified exactly as the import qualified them, so a redefined + [settle] lands on [sim/settle] and its call to [move-grain] lands on + [sim/move-grain]. A name the package does not own — the prelude's, or + another package's — is left alone, which is the same rule [Load] uses + at import time and the reason both go through [qualify_decl]. *) + let owns = + p.Load.owns + @ List.filter_map Ast.declared_name ds + in + List.map (Load.qualify_decl owns p.Load.alias) ds + in let loc = match incoming with d :: _ -> d.Ast.dloc | [] -> Loc.unknown in diff --git a/test/test_session.ml b/test/test_session.ml index fd30a2e..be16c62 100644 --- a/test/test_session.ml +++ b/test/test_session.ml @@ -127,6 +127,31 @@ let () = | exception Loc.Error (_, m) -> fail "reloading a file with imports failed: %s" m); + (* A form typed into a file that is *imported as a package* has to be + qualified the way the import qualified it, or it splices as a brand-new + unrelated name: the evaluation reports success and the running program + goes on calling the one it already had. The alias is chosen by the + importer and written nowhere in the file, so the path is the only thing + that can decide it — which is why it is derived here and not sent by the + editor. *) + let t, _ = Session.create ~file:"../sand.flan" in + (match + Session.eval ~origin:"../sand-sim/sim.flan" t + "(defn settle [row i32 col i32] Unit (do))" + with + | c -> + if c.Session.fns <> [ "sim/settle" ] then + fail "a form from a package file reported %s, wanted sim/settle" + (String.concat " " c.Session.fns) + | exception Loc.Error (_, m) -> fail "redefining sim/settle: %s" m); + (* And a file that is not a package keeps its names as written. *) + (match Session.eval ~origin:"../sand.flan" t "(defn game-draw [] Unit (do))" with + | c -> + if c.Session.fns <> [ "game-draw" ] then + fail "a form from the program's own file reported %s" + (String.concat " " c.Session.fns) + | exception Loc.Error (_, m) -> fail "redefining game-draw: %s" m); + if !failures = 0 then print_endline "session: all tests passed" else begin Printf.printf "\n%d failure(s)\n" !failures;