diff --git a/TODO.org b/TODO.org
index fe34908c..f12d0401 100644
--- a/TODO.org
+++ b/TODO.org
@@ -524,13 +524,20 @@ both.
** DONE A package cannot mark a name private
CLOSED: [2026-09-25]
-=defn-= declares a function private to its package; it is a =defn= otherwise.
-A use from a file outside the package's directory — a call or the name as a
-value, from the importer or from another package — is refused in =Check=, so it
-holds under C-c C-c too. Functions only. A single-file package's siblings share
-its directory and can call its =defn-=s, and a package macro expanding to a
-=defn-= call is refused at the expansion. The edn and json internals are now
-=defn-=; =rl/get-color-raw= no longer exists. =docs/BUILT.md= has the placement.
+=defn-= declares a function private to its module; it is a =defn= otherwise.
+The module is the import boundary: every file of a directory package, or the
+one file of a package imported by naming the file. A use from outside — a call
+or the name as a value — is refused in =Check=, so it holds under C-c C-c too.
+Code the package's own macro wrote counts as inside (SBCL's rule, not
+Clojure's); what the importer wrote, passed through or from its own macro, does
+not. Functions only. The edn and json internals are now =defn-=;
+=rl/get-color-raw= no longer exists. =docs/BUILT.md= has the placement.
+
+** WAIT A private scoped to one file of a directory package
+Deferred until a need appears: Odin's =@(private="file")=, a function visible
+to its own file only when the package is a directory. =defn-= covers the
+package; a one-file package already gets file scope because the file is the
+module.
** CANCELLED A struct version word, so a redefined layout keeps working
CLOSED: [2026-09-20]
diff --git a/docs/BUILT.md b/docs/BUILT.md
index e307e696..3512eb84 100644
--- a/docs/BUILT.md
+++ b/docs/BUILT.md
@@ -794,18 +794,29 @@ takes a macro-stamped location — and `Loc.from_macro` sets a name and leaves f
the frame the break loop reports is still the line the reader is looking at. `temps` is not reset on this path, unlike `decl`'s — a declaration is
a fresh top level, an expression is evaluated into a session that has been handing out temporaries all along.
-A function can be private to its package: `defn-`, Clojure's spelling, is a `defn` whose uses outside the package's
-directory are refused. It is not done in `load.ml`, although `main`'s refusal is. `Load.refuse_hidden` walks every
+A function can be private to its module: `defn-`, Clojure's spelling, is a `defn` whose uses outside the module that
+declares it are refused. It is not done in `load.ml`, although `main`'s refusal is. `Load.refuse_hidden` walks every
declaration after the rename, the package's own included, and by then the package's calls to its helper read
`alias/helper` like anyone else's; and a C-c C-c goes through `Load.program` with one form and no imports in sight. So
-the flag rides on `Ast.fn`, `Check` records every `defn-` in `privates`, and `Check.private_ref` compares the directory
-of the file the use is written in with the directory of the file the definition is — the same shape as
-`shadows_builtin`'s file test. Being in `Check` is what makes it hold in the session too: every evaluation re-checks
-the whole declaration list. Only a qualified name is asked about, because an unqualified one is the program's own.
-Two consequences follow from the rule being about files. A single-file package shares its directory with its
-siblings, and they can call its `defn-`s. And a package macro that expands to a call to one of its `defn-`s is
-refused at the expansion, because the expansion is located at the call site in the importer's file; the edn and json
-readers' generated code calls `need-int` and its neighbours, which is why those stay `defn`.
+the flag rides on `Ast.fn`, `Check` records every `defn-` in `privates`, and `Check.private_ref` compares the file the
+use is written in with the file the definition is — the same shape as `shadows_builtin`'s file test. Being in `Check`
+is what makes it hold in the session too: every evaluation re-checks the whole declaration list. Only a qualified name
+is asked about, because an unqualified one is the program's own.
+
+The module is what `Load` imports as one unit. For a directory package that is every `.flan` file in the directory, so
+the test is the two files' directories. For a package imported by naming a single file it is that file, and its
+siblings in the directory are not part of it; the parser cannot tell which kind it is reading, so `Load.file_scoped`
+narrows the flag to `Private_to_file` at import, and `Session.eval` does the same for a C-c C-c in such a file. A
+file-only private inside a directory package, Odin's `@(private="file")`, is not provided.
+
+Code a package's own macro wrote counts as inside the package wherever it is expanded, which is SBCL's rule: an
+expansion there refers to the package's internal symbols freely. Clojure refuses the same thing. `Expand.unmarshal`
+already separates the two kinds of node an expansion holds. A node the macro invented carries the call site's
+location, tagged with the macro's name and no separate call site; a form the author wrote and the macro passed
+through keeps its own position, with the call recorded in `msite`. So an invented node whose macro has the function's
+qualifier is accepted, and an author's form is judged by the file it sits in. The macro name is the outermost one,
+because the tag is outermost-wins: a call written by a package macro that was itself expanded out of an importer's
+macro is judged as the importer's.
## The link follows the program
diff --git a/lib/ast.ml b/lib/ast.ml
index bd5bbf68..066a0e28 100644
--- a/lib/ast.ml
+++ b/lib/ast.ml
@@ -208,6 +208,12 @@ and pattern =
makes each instantiation check the concrete type answers yes. *)
type pred = { pname : string; pvar : string; ploc : Loc.t }
+(* Who may use a function. [defn-] parses as [Private_to_package]; [Load]
+ narrows it to [Private_to_file] when the package is a single file named
+ outright, because that file is the whole module and its siblings in the
+ directory are not part of it. [Check.private_ref] is what enforces both. *)
+type privacy = Exported | Private_to_package | Private_to_file
+
type fn = {
name : string;
params : field list;
@@ -225,9 +231,7 @@ type fn = {
fwhere : pred list;
fbody : expr list;
nloc : Loc.t;
- (* Written [defn-]: callable only from the files of the package that
- declares it. [Check.private_ref] is the whole of what it means. *)
- fprivate : bool;
+ fprivate : privacy;
}
type decl = { d : decl_kind; dloc : Loc.t }
diff --git a/lib/check.ml b/lib/check.ml
index c66b777c..11bae323 100644
--- a/lib/check.ml
+++ b/lib/check.ml
@@ -114,8 +114,9 @@ type env = {
function can show it. Kept apart from [fparams] because a foreign
[declare] has a location and no parameter vector worth showing. *)
fn_locs : (string, Loc.t) Hashtbl.t;
- (* Every [defn-], by name, with where it was written. See [private_ref]. *)
- privates : (string, Loc.t) Hashtbl.t;
+ (* Every [defn-], by name, with where it was written and how far it is
+ visible. See [private_ref]. *)
+ privates : (string, Loc.t * Ast.privacy) Hashtbl.t;
globals : (string, Types.t * bool) Hashtbl.t; (* type, is a constant *)
(* Where each global was declared, so a refusal about one can show it. A
second table rather than a third field, because every other reader of
@@ -9008,32 +9009,61 @@ and ordinary_call ctx ~want loc name args =
is not the file the defn was written in, so it reaches the builtin. That
is the conservative direction, and C-c C-c — which sends the buffer's own
path — is not affected. *)
-(* A [defn-] is its package's own. A use of one — a call, or the name taken
- as a value — is refused unless it is written in a file of the package that
- declares it, and a package is its directory, so the test is the directory of
- the two files. Realpath'd, because one side is usually the path the importer
- was given on the command line and the other the one [Load] resolved.
+(* A [defn-] is its module's own. A use of one — a call, or the name taken
+ as a value — is refused unless it is written inside the module that
+ declares it. A directory package is every file in its directory, so the
+ test there is the two files' directories; a single file imported outright
+ is that file alone, which [Load] records by narrowing the flag to
+ [Private_to_file]. Realpath'd, because one side is usually the path the
+ importer was given on the command line and the other the one [Load]
+ resolved.
+
+ Code the package's own macro wrote counts as inside, wherever it was
+ expanded: SBCL's rule, where a macro's expansion refers to its package's
+ internal symbols freely. What the importer wrote itself does not, even when
+ it is an argument the macro passed through. [Expand.unmarshal] tells the two
+ apart already: a node the macro invented carries the call site's location
+ with the macro's name on it and no separate call site, and a form the author
+ wrote keeps its own position with the call recorded beside it. The macro's
+ package is its qualifier, which is the function's when both come from the
+ same module, because an import qualifies every name a directory declares
+ under the one alias it is read as.
Only a qualified name is asked about. An unqualified one belongs to the
- program being built, and nothing outside a program can name it — the REPL,
- which evaluates with no file behind it, included.
-
- A package that is a single file named outright, rather than a directory,
- shares its directory with whatever sits beside it, so a sibling file that
- imports it can call its [defn-]s. *)
+ program being built, and nothing outside a program can name it. *)
and private_ref ctx loc name =
+ let qualifier n =
+ match String.rindex_opt n '/' with
+ | Some i -> Some (String.sub n 0 i)
+ | None -> None
+ in
+ let written_by_own_macro () =
+ match loc.Loc.macro, loc.Loc.msite with
+ | Some m, None -> qualifier m <> None && qualifier m = qualifier name
+ | _ -> false
+ in
match Hashtbl.find_opt ctx.env.privates name with
- | Some at when String.contains name '/' ->
- let dir file =
- Filename.dirname
- (try Unix.realpath file with Unix.Unix_error _ -> file)
+ | Some (at, scope) when String.contains name '/' ->
+ let real file = try Unix.realpath file with Unix.Unix_error _ -> file in
+ let inside =
+ match scope with
+ | Ast.Private_to_file -> String.equal (real at.Loc.file) (real loc.Loc.file)
+ | _ ->
+ String.equal (Filename.dirname (real at.Loc.file))
+ (Filename.dirname (real loc.Loc.file))
in
- if not (String.equal (dir at.Loc.file) (dir loc.Loc.file)) then
+ if not inside && not (written_by_own_macro ()) then begin
+ let where =
+ match scope with
+ | Ast.Private_to_file -> real at.Loc.file
+ | _ -> "the files in " ^ Filename.dirname (real at.Loc.file)
+ in
Loc.failk "check/private" loc
~notes:[ Loc.note at (Printf.sprintf "%s is declared here" name) ]
- "%s is private to its package: it is declared with defn-, and a \
- defn- can be used only from a file in %s"
- name (dir at.Loc.file)
+ "%s is private to its package: it is declared with defn-, so only %s \
+ can use it. Declaring it with defn instead makes it usable from here"
+ name where
+ end
| _ -> ()
and shadows_builtin ctx loc name =
@@ -10472,8 +10502,9 @@ let collect env (decls : Ast.decl list) =
in
env.tyvars <- [];
env.tvpreds <- [];
- if fn.Ast.fprivate then
- Hashtbl.replace env.privates fn.Ast.name fn.Ast.nloc;
+ if fn.Ast.fprivate <> Ast.Exported then
+ Hashtbl.replace env.privates fn.Ast.name
+ (fn.Ast.nloc, fn.Ast.fprivate);
if vars = [] then begin
Hashtbl.replace env.fns fn.Ast.name (params, ret);
Hashtbl.replace env.fparams fn.Ast.name fn.Ast.params;
diff --git a/lib/cimport.ml b/lib/cimport.ml
index 8091fe69..0e4823c9 100644
--- a/lib/cimport.ml
+++ b/lib/cimport.ml
@@ -838,7 +838,7 @@ let of_dump ~env ~taken ~bound_syms ~config (d : dump) : imported =
decls :=
{ Ast.d =
Ast.DeclareC
- ({ Ast.name = flan; params; praw = None; ret; fwhere = []; fbody = []; nloc = f.cloc; fprivate = false },
+ ({ Ast.name = flan; params; praw = None; ret; fwhere = []; fbody = []; nloc = f.cloc; fprivate = Ast.Exported },
f.csym);
dloc = f.cloc }
:: !decls)
diff --git a/lib/classes.ml b/lib/classes.ml
index a86cfa84..a6b7325b 100644
--- a/lib/classes.ml
+++ b/lib/classes.ml
@@ -175,7 +175,7 @@ let constructor n slots loc : Ast.decl =
Ast.Defn
{ Ast.name = n; params; praw = None; ret = Some (dyn_at loc);
fwhere = []; fbody = [ ex loc (Ast.MapLit (Some n, pairs)) ];
- nloc = loc; fprivate = false };
+ nloc = loc; fprivate = Ast.Exported };
dloc = loc }
(* The dispatch value a method answers for, as an expression to compare
diff --git a/lib/load.ml b/lib/load.ml
index c41e8ee5..8fa0b01d 100644
--- a/lib/load.ml
+++ b/lib/load.ml
@@ -26,7 +26,7 @@
would collide with the importer's, and worse, would keep everything it
calls reachable (see [Reach]) — which for a raylib front-end is the whole
library, on the target that cannot link it. And a [defn-] is exported like
- any other name but refused at a use outside its package's directory, which
+ any other name but refused at a use outside its package, which
is [Check.private_ref]'s and not this module's: the rename has to qualify
the package's own calls to it exactly as it qualifies everything else.
@@ -903,6 +903,20 @@ let refuse_hidden hidden ds =
(* Every top-level name the package declares — types and values alike, since a
use site is rewritten by name and the two never collide in one namespace. *)
+(* A [defn-] in a package that is one file named outright is private to that
+ file: the file is the whole module, and whatever else sits in its directory
+ is not part of it. The parser cannot know which kind of package it is
+ reading, so the flag is narrowed here and in [Session.eval], the two places
+ that do. *)
+let file_scoped (ds : Ast.decl list) =
+ List.map
+ (fun (d : Ast.decl) ->
+ match d.Ast.d with
+ | Ast.Defn ({ Ast.fprivate = Ast.Private_to_package; _ } as fn) ->
+ { d with Ast.d = Ast.Defn { fn with Ast.fprivate = Ast.Private_to_file } }
+ | _ -> d)
+ ds
+
let owned_names (ds : Ast.decl list) =
List.filter_map Ast.declared_name ds
@@ -1413,6 +1427,7 @@ let rec import ~seen ~open_ ~loc alias dir =
in
let owned = List.filter exported (owned_names ds) in
let decls = List.map (qualify_decl owned alias) own in
+ let decls = if one_file then file_scoped decls else decls in
let lflags = if one_file then [] else link_flags dir in
let csrcs = if one_file then [] else entries dir ".c" in
let phidden =
diff --git a/lib/parse.ml b/lib/parse.ml
index d413937e..43f9aec3 100644
--- a/lib/parse.ml
+++ b/lib/parse.ml
@@ -1377,7 +1377,9 @@ let rec decl (f : Form.t) : Ast.decl =
(* [defn-] is a [defn] in every respect but one: [Check.private_ref] refuses
a use of it from outside the package that declares it. *)
| List ({ v = Sym ("defn" | "defn-" as head); _ } :: args) ->
- let fprivate = String.equal head "defn-" in
+ let fprivate =
+ if String.equal head "defn-" then Ast.Private_to_package else Ast.Exported
+ in
(match args with
| n :: { v = Vec ps; _ } :: ret :: body ->
(* The slot's own failure, because the thing found there is almost
@@ -1467,7 +1469,7 @@ let rec decl (f : Form.t) : Ast.decl =
else fun fn -> Ast.Defmulti fn)
{ Ast.name = sym n; params = dyn_params which ps; praw = None;
ret = Some (texpr ret); fwhere = []; fbody = body_of body;
- nloc = n.loc; fprivate = false })
+ nloc = n.loc; fprivate = Ast.Exported })
| _ -> fail f "%s" usage)
| List ({ v = Sym "defmethod"; _ } :: args) ->
@@ -1481,7 +1483,7 @@ let rec decl (f : Form.t) : Ast.decl =
mfn = { Ast.name = gen ^ "@" ^ Ast.dispatch_text k;
params = dyn_params "defmethod" ps; praw = None;
ret = None; fwhere = []; fbody = body_of body;
- nloc = n.loc; fprivate = false } })
+ nloc = n.loc; fprivate = Ast.Exported } })
| _ ->
fail f
"defmethod is (defmethod generic dispatch [param ...] body ...). \
@@ -1513,11 +1515,11 @@ let rec decl (f : Form.t) : Ast.decl =
| [ n; { v = Form.Vec ps; _ } ] ->
mk (mkd { Ast.name = sym n; params = fields f ps; praw = None;
ret = None; fwhere = []; fbody = []; nloc = n.loc;
- fprivate = false } csym)
+ fprivate = Ast.Exported } csym)
| [ n; { v = Form.Vec ps; _ }; r ] ->
mk (mkd { Ast.name = sym n; params = fields f ps; praw = None;
ret = Some (texpr r); fwhere = []; fbody = [];
- nloc = n.loc; fprivate = false } csym)
+ nloc = n.loc; fprivate = Ast.Exported } csym)
| _ -> fail f "%s" usage)
| _ -> fail f "%s" usage)
@@ -1749,7 +1751,7 @@ let rec decl (f : Form.t) : Ast.decl =
leave off. *)
praw = None;
ret = Some form_t; fwhere = []; fbody = macro_body sg body;
- nloc = n.loc; fprivate = false })
+ nloc = n.loc; fprivate = Ast.Exported })
| _ ->
fail f "defmacro is (defmacro name [param ...] body ...)")
diff --git a/lib/session.ml b/lib/session.ml
index b54592e2..191853ea 100644
--- a/lib/session.ml
+++ b/lib/session.ml
@@ -644,7 +644,8 @@ let eval ?(origin = "
A function declared with defn- is private to its package.
It is a defn in every other respect, and every file in the package's
-directory can call it. A use from anywhere else — a call, or the name passed as a
-value — is refused at compile time, and the message names the function and the
-directory it belongs to. Only functions have a private form. A single-file package
-shares its directory with the files beside it, so those files can call its
-defn-s too.
A package may carry the C it binds to. Every .c file in the directory is
compiled into the build, and a file named link lists extra linker