A defn- is private to its module, the package's own macros may write calls to it, and the refusal names the fix

This commit is contained in:
Joseph Ferano 2026-09-25 08:55:37 +07:00
parent 376e990689
commit 793b0eea9e
20 changed files with 227 additions and 63 deletions

View File

@ -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]

View File

@ -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

View File

@ -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 }

View File

@ -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;

View File

@ -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)

View File

@ -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

View File

@ -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 =

View File

@ -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 ...)")

View File

@ -644,7 +644,8 @@ let eval ?(origin = "<eval>") ?pause t src : change =
p.Load.owns
@ List.filter_map Ast.declared_name ds
in
List.map (Load.qualify_decl owns p.Load.alias) ds
let ds = List.map (Load.qualify_decl owns p.Load.alias) ds in
if Load.is_package_file p.Load.dir then Load.file_scoped ds else ds
in
let loc =
match incoming with d :: _ -> d.Ast.dloc | [] -> Loc.unknown

View File

@ -68,6 +68,7 @@
; pkg-private*.flan.
(glob_files programs/pkgs/secret/*)
(glob_files programs/pkgs/nosy/*)
(glob_files programs/loose/*)
; The synthetic C header the importer's table reads. Committed rather than
; reached for on the machine: the raylib case needs raylib installed, at the
; right version, with a variable set, so it skips everywhere and covers
@ -241,7 +242,8 @@
(glob_files programs/pkgs/shadowed/*)
(glob_files programs/pkgs/gen/*)
(glob_files programs/pkgs/secret/*)
(glob_files programs/pkgs/nosy/*))
(glob_files programs/pkgs/nosy/*)
(glob_files programs/loose/*))
(action (run ./test_valgrind.exe)))
; The corpus a fourth time, through the hand-written x86-64 backend, compared
@ -303,7 +305,8 @@
(glob_files programs/pkgs/shadowed/*)
(glob_files programs/pkgs/gen/*)
(glob_files programs/pkgs/secret/*)
(glob_files programs/pkgs/nosy/*))
(glob_files programs/pkgs/nosy/*)
(glob_files programs/loose/*))
(action
(setenv SURVEY_STRICT 1
(setenv SURVEY_QUIET 1
@ -482,7 +485,8 @@
(glob_files programs/pkgs/shadowed/*)
(glob_files programs/pkgs/gen/*)
(glob_files programs/pkgs/secret/*)
(glob_files programs/pkgs/nosy/*))
(glob_files programs/pkgs/nosy/*)
(glob_files programs/loose/*))
(action
(setenv SURVEY_STRICT 1
(setenv SURVEY_QUIET 1

View File

@ -0,0 +1,7 @@
;;;; A package that is one file, imported by naming the file. Its defn- is
;;;; private to this file: the files beside it in the directory are not part
;;;; of it.
(defn- inner [a i32] i32 (* a 3))
(defn outer [a i32] i32 (inner a))

View File

@ -0,0 +1,8 @@
;;;; Sits beside lib.flan and imports it by name. lib's defn- is private to
;;;; lib.flan, so the shared directory does not make it callable here.
(import lib "lib.flan")
(defn main [] i32
(print (lib/inner 5))
0)

View File

@ -0,0 +1,7 @@
;;;; Imports lib.flan by name and calls its public function.
(import lib "lib.flan")
(defn main [] i32
(print (lib/outer 5)) (println "")
0)

View File

@ -0,0 +1,10 @@
;;;; A call to a package's private function written by a macro of this file,
;;;; not of the package.
(import secret "pkgs/secret")
(defmacro sneak [& args] `(do (secret/mix 1 2)))
(defn main [] i32
(print (sneak))
0)

View File

@ -0,0 +1,9 @@
;;;; A call to a package's private function written here and handed to the
;;;; package's own macro. The macro passes it through; the call is still this
;;;; file's.
(import secret "pkgs/secret")
(defn main [] i32
(print (secret/pass (secret/mix 1 2)))
0)

View File

@ -1,5 +1,6 @@
;;;; A package's private function, used from inside the package: from the
;;;; file that declares it, from the package's other file, and as a value.
;;;; file that declares it, from the package's other file, as a value, and
;;;; through code the package's own macros write here.
(import secret "pkgs/secret")
@ -7,4 +8,6 @@
(print (secret/combine 1 2)) (println "")
(print (secret/twice 3)) (println "")
(print (secret/via-value 4 5)) (println "")
(print (secret/mixed 6 7)) (println "")
(print (secret/mixed-in-do 8 9)) (println "")
0)

View File

@ -5,3 +5,14 @@
(defn- mix [a i32 b i32] i32 (+ (* a 10) b))
(defn combine [a i32 b i32] i32 (mix a b))
;; Code these macros write is the package's own, wherever it is expanded: the
;; call to mix they answer with is accepted in the importer's file, on its own
;; and nested inside a do.
(defmacro mixed [& args] `(mix ~(at args 0) ~(at args 1)))
(defmacro mixed-in-do [& args] `(do (mix ~(at args 0) ~(at args 1))))
;; What the importer writes and this macro only passes through is still the
;; importer's.
(defmacro pass [& args] (at args 0))

View File

@ -2796,7 +2796,12 @@ let () =
declares it, the package's other file, and as a value handed out from
there. The refusals are with the others, further down. *)
outputs "a defn- used inside its package" "programs/pkg-private.flan"
"12\n33\n45\n";
"12\n33\n45\n67\n89\n";
(* A single-file package's defn- is callable from that file, and the file
beside it that imports it reaches the public function. The refusal for
the same sibling calling the defn- directly is with the others. *)
outputs "a single-file package calls its own defn-"
"programs/loose/use.flan" "15\n";
(* A defn named after a builtin, and the boundary the shadow stops at.
The numbers are the whole claim and none of them could be printed by
the other reading: 7 is the program's own one-argument (get p), which
@ -3085,6 +3090,20 @@ let () =
"programs/pkg-private-nested.flan" "secret/mix is private to its package";
refuses "and the refusal names the package's directory"
"programs/pkg-private-call.flan" "pkgs/secret";
refuses "and names the fix"
"programs/pkg-private-call.flan" "Declaring it with defn instead";
(* The package's own macro may write a call to its defn- (that half is in
pkg-private.flan); what the importer writes is the importer's, whether
the package's macro passes it through or the importer's macro wrote it. *)
refuses "a defn- call the importer wrote, passed through the package's macro"
"programs/pkg-private-passed.flan" "secret/mix is private to its package";
refuses "a defn- call written by the importer's own macro"
"programs/pkg-private-own-macro.flan"
"secret/mix is private to its package";
(* A single file named outright is the whole module, so its defn- is not
visible to the file beside it in the directory. *)
refuses "a single-file package's defn-, from the file beside it"
"programs/loose/peek.flan" "lib/inner is private to its package";
refuses "one directory under two aliases" "programs/pkg-two-aliases.flan"
"one directory takes one alias";
(* A ring is refused and the ring is named. The needle is the chain, not

View File

@ -615,6 +615,20 @@ let () =
| _ -> ()
| exception Loc.Error { Loc.dmsg = m; _ } ->
fail "a defn- in the program's own file: %s" m);
(* And a single-file package: redefined from its own file, it stays private
to that file, so the file beside it that imports it is still refused. *)
let tl, _ = Session.create ~file:"programs/loose/use.flan" () in
(match Session.eval ~origin:"programs/loose/lib.flan" tl
"(defn- inner [a i32] i32 (* a 4))" with
| _ -> ()
| exception Loc.Error { Loc.dmsg = m; _ } ->
fail "redefining a single-file package's defn-: %s" m);
(match Session.eval ~origin:"programs/loose/use.flan" tl
"(defn peek [] i32 (lib/inner 1))" with
| _ -> fail "a redefined single-file defn- became callable beside its file"
| exception Loc.Error { Loc.dmsg = m; _ } ->
if not (has m "lib/inner is private to its package") then
fail "a call to a single-file defn- was refused for another reason: %s" m);
(* ── C-x C-e expands, which it never used to ────────────────────────
[Parse.expr] did not call the expander at all, so an expression typed at

View File

@ -1528,11 +1528,12 @@ existed.</p>
<p><strong>A function declared with <code>defn-</code> is private to its package.</strong>
It is a <code>defn</code> 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
<code>defn-</code>s too.</p>
directory can call it. For a package imported by naming a single file, the package is
that file alone, and the other files in its directory cannot 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 where it may be used. Code written by one of
the package's own macros counts as the package's, wherever the macro is called. Only
functions have a private form.</p>
<p>A package may carry the C it binds to. Every <code>.c</code> file in the directory is
compiled into the build, and a file named <code>link</code> lists extra linker