builtin/ always reaches the compiler's own name

# Conflicts:
#	FIX.org
This commit is contained in:
Joseph Ferano 2026-09-20 20:27:35 +07:00
commit 8d49d0dddc
7 changed files with 524 additions and 8 deletions

180
FIX.org
View File

@ -2502,10 +2502,13 @@ program of twenty thousand calls, measured in review. The list stays for the
did-you-mean, whose order is its order; the set answers the membership.
** The warning, verbatim
: shadow-builtin.flan:20:7: warning: get shadows the builtin get — every call in this program now reaches your definition
: shadow-builtin.flan:20:7: warning: get shadows the builtin get — every call in this program now reaches your definition — the builtin stays reachable as builtin/get
: 20 | (defn get [p P] i32 (.x p))
: | ~~~
The clause after the second dash arrived a day later with the escape hatch
itself; this entry shipped without it, because there was nothing to name.
Rendered by ~Loc.entry ~mark:'~' ~label:"warning: "~, which is the
~--warn-memory~ precedent, so flycheck parses it exactly as it parses an
error. Nothing raises and the exit status does not move. Unlike
@ -2587,6 +2590,14 @@ stack-overflows at run time with no diagnostic from the compiler, which has
nothing to object to. The warning says the name is taken over; it does not
say this. An escape hatch is a language decision and is with the author.
/Closed the next day./ The author's answer was the qualified spelling — see
"builtin/, the reserved qualifier, 2026-09-20" below. ~(defn len [s string]
i32 (builtin/+ 1 (builtin/len s)))~ is the wrapper this paragraph said could
not be written, and it runs: ~programs/builtin-qualified.flan~ prints 5 for
it beside the builtin's own 4. The warning's sentence now carries the escape,
so the reader is told what is left at the moment they are told the name was
taken over.
** Pins
- ~test_flan.ml~: the warning's kind, line and column; its message, matched
whole and not by needle; that it carries no notes; that the source which
@ -3380,3 +3391,170 @@ by =Unix._exit 0=, neither of which runs an atexit. The socket sits in
above, "The daemon leaves its temp directory behind", still open. No separate
fix is wanted here; the session-end cleanup that item asks for takes the
socket with it.
* builtin/, the reserved qualifier, 2026-09-20
The author's decision, in the author's words:
#+begin_quote
"the full spelling is fine, I like that."
#+end_quote
This closes the dead end disclosed the same day by "Shadowing a builtin,
2026-09-20": a defn named after a builtin wins program-wide inside its own
file, and before this there was no remaining spelling for the thing it had
taken over, so a definition that meant to *wrap* a builtin was unbounded
recursion with no diagnostic. ~builtin/len~ is the builtin ~len~, whatever
else the file has decided ~len~ means, and it is legal whether or not
anything is shadowed — a spelling that only compiled while some other
declaration existed would be one nobody could write down in advance.
** The resolver
Two interceptions, both at the very top of the dispatch they sit in, and each
strips the prefix and re-enters the same function with one flag set:
: | _ when not qualified && qualified_builtin name <> None ->
: let bare = Option.get (qualified_builtin name) in
: if not (Hashtbl.mem builtin_set bare) then not_a_builtin loc bare;
: named_call ~qualified:true ctx ~want loc bare args
~named_call~ gains ~?(qualified = false)~ and the shadowing guard beneath it
becomes ~not qualified && shadows_builtin ...~. That flag is the whole of the
mechanism: a qualified call has already said which of the two readings it
means, so there is nothing left for shadowing to decide, and every arm below
sees the *bare* name — which is why ~(builtin/len 1 2)~ is refused with
exactly the sentence ~(len 1 2)~ would get. It cannot loop:
~builtin/builtin/len~ strips once and is then refused by name, because
~builtin/len~ is not in ~builtin_set~.
~var~ gains the same arm, for the builtins that are names rather than calls —
~true~, ~false~, ~nil~, ~None~, ~context/allocator~, ~context/temp~. The last
two fall out for free: stripping one prefix off ~builtin/context/allocator~
leaves a name the match already has an arm for.
*The one asymmetry, and it is load-bearing.* ~var~'s qualified path must
refuse where the call path falls through. A qualified name that gets past the
value arms is in ~builtin_set~ but is call-only, and letting it fall into
~lookup~/~globals~/~fns~ would answer ~builtin/len~ with the address of the
very definition the qualifier was written to escape — the feature inverted,
silently. So a guarded ~| _ when qualified ->~ arm sits above the catch-all:
: builtin/len is the builtin len, which is a call and not a value — a builtin
: has no address to pass. Write (builtin/len ...) at the call, or wrap it in a
: defn to pass that
** Why ~/~ and not ~(builtin get)~
The spelling is the package qualifier's, deliberately. A reader who knows
that ~rl/draw-fps~ is ~draw-fps~ from the package imported as ~rl~ already
knows what ~builtin/len~ is and needs no second syntax. What makes it
unambiguous is that ~builtin~ is *reserved* rather than resolved: every
qualifier in a finished program comes from ~Load.qualify~, and every
~qualify~ takes its alias from an ~import~ form, so refusing that one alias
is the whole of the reservation — there is no other door.
The refusal is at the top of ~Load.import~, before the package is read, which
also covers a package importing one under that alias since every import goes
through that function:
: builtin is a reserved qualifier and cannot be an import alias: builtin/name
: always means the compiler's builtin, which is how a program reaches a
: builtin it has shadowed. Import this package under another alias
*It is the alias and not the directory.* The task that asked for this said "a
package directory named ~builtin~ must be refused at import", and the code
says something slightly narrower, because the alias is always written out —
~(import rl "vendor:raylib")~, ~parse.ml~'s two-element form — and a
directory never becomes a qualifier on its own. A package whose directory is
called ~builtin~ imports fine under any other name and collides with nothing;
~(import builtin "anything")~ is what is refused. The directory is not named
in the message either: it has been resolved to an absolute path by then, and
the caret is already under the import form, which carries the path the reader
wrote.
*And from the other side.* ~(defn builtin/len ...)~ reads — ~/~ is an
ordinary symbol character — and would land in ~env.fns~ under a name nothing
could ever call, since the prefix is stripped before any table is consulted.
~Check.collect~ refuses any declaration whose name carries the prefix, over
~Ast.declared_name~ so it covers every declaration kind at once:
: builtin/len cannot be declared: builtin/ is a reserved qualifier, so a name
: spelled with it reaches the compiler's builtins and never a declaration —
: nothing could call this one
** The reader, and ~builtin/+~
No exception was needed. ~reader.ml~'s ~is_delimiter~ makes ~/~ ordinary and
says so in its own comment (~rl/draw-fps~ is one symbol), and the operator
characters are ordinary for the same reason ~+~ is a symbol at all. The one
path that could have taken ~builtin/+~ apart is the number reader, which
takes a token starting with a digit or with ~-~/~+~ followed by a digit, and
~builtin/+~ starts with ~b~. So it arrives as one symbol and there is nothing
to document as unreadable. ~(builtin/+ 1 2)~ is 3 in a file whose ~+~ answers
99, and it lowers to the ~Add~ prim rather than to a ~Call~.
** The refusals for a qualifier that reaches nothing
Its own kind, ~check/unknown-builtin~, and the did-you-mean is over
~builtin_names~ *alone* — not over the program's own names. The reader wrote
the qualifier, so they were reaching for a compiler name, and offering them a
defn called ~lem~ would answer a question they did not ask. Every other
did-you-mean in ~check.ml~ keeps the candidates it already had, and the
dot-access diagnostic is untouched: nothing anywhere suggests a ~builtin/~
spelling for a name that was written bare.
: nosuch is not a builtin, so builtin/nosuch reaches nothing. The builtin/
: qualifier reaches the compiler's own names and nothing else; an ordinary
: function is called by the name it was defined under
: lne is not a builtin, so builtin/lne reaches nothing — did you mean
: builtin/len?
: builtin/ needs a name after it — the qualifier reaches a builtin, as
: (builtin/len v)
** The warning now names the escape
: shadow-builtin.flan:20:7: warning: get shadows the builtin get — every call in this program now reaches your definition — the builtin stays reachable as builtin/get
One sentence still, and the second half is the half the reader wants next:
they are being told the name was taken over, and what is left is the thing
they are about to go looking for.
** The arm-scraper, which nearly broke twice
~test_flan.ml~ reads ~check.ml~'s source to cross-check the builtin arms
against ~Check.builtins~, keying the two regions on the literal prefixes
~"and named_call "~ and ~"and var ctx "~. So the optional argument had to go
*after* ~ctx~ in ~var~ — ~and var ctx ?(qualified = false) loc ~want name~
and a leading one would have emptied that region and reported ~true~,
~false~, ~nil~, ~None~ and the two ~context/~ names as deleted arms. Both new
arms are guarded and carry no string literal in the head, so the scraper skips
them exactly as it skips the shadowing guard; nothing in that test changed.
** Pins
- ~test_flan.ml~: ~builtin/len~ checks with nothing shadowed; with a
two-parameter ~len~ in the way, the bare call at two arguments checks and
the qualified one is refused at *the builtin's* arity ("len takes 1
argument, given 2") — the pair is refusable only under one reading each, so
it cannot pass under both.
- ~test_flan.ml~: the self-referential wrapper ~(defn len [s string] i32
(builtin/+ 1 (builtin/len s)))~ checks and its body holds no ~Call ("len",
_)~ anywhere, walked with ~Tast.walk~ — the difference between a wrapper
and a loop, asserted rather than assumed.
- ~test_flan.ml~: ~builtin/+~ with ~+~ shadowed lowers to ~Prim (Add, _)~;
~builtin/nil~ and ~builtin/context/allocator~ check; ~builtin/len~ in a
value position is refused with the call-and-not-a-value sentence.
- ~test_flan.ml~: ~check/unknown-builtin~ as a kind, both of its messages
whole (with and without the near miss), and the declaration refusal; plus
that a *bare* typo is still answered bare ("did you mean len?") and never
with a qualifier.
- ~test_flan.ml~, changed: the two shadow-warning messages, matched whole,
now carry the escape clause.
- ~test_acceptance.ml~: ~programs/builtin-qualified.flan~ outputs
~9\n5\n4\n99\n3\n~ — builtin/max unshadowed, the wrapper's 5, builtin/len's
4 beside it, the shadowed operator's 99, builtin/+'s 3. It is an ordinary
corpus row, so the ~@x86~ sweep compares both backends over it.
- ~test_acceptance.ml~: ~programs/import-builtin-alias.flan~ is refused, by
the alias sentence and by the clause saying what the qualifier is for.
** What was run
~dune test~ in the lane's worktree, green. Per the batching policy the ~@x86~
and ~@sanitize~ sweeps were not run here — the new corpus row is registered
for the x86 survey through the existing ~programs/*.flan~ glob and will be
compared on the next sweep.

View File

@ -319,6 +319,56 @@ let builtin_names : string list ref = ref []
twenty thousand calls. Filled beside the list. *)
let builtin_set : (string, unit) Hashtbl.t = Hashtbl.create 128
(* ── builtin/, the reserved qualifier ──────────────────────────────────
[builtin/len] is the builtin [len], whatever else the program has decided
[len] means. It is the way out of the dead end shadowing used to leave: a
[(defn len ...)] takes the bare name over for its whole file, and before
this there was no remaining spelling for the thing it was wrapping, so the
wrapper was unbounded recursion instead.
The spelling is the package qualifier's, deliberately. A reader who knows
that [rl/draw-fps] is [draw-fps] from the package imported as [rl] already
knows what [builtin/len] is, and needs no second syntax to learn. What
makes it work is that [builtin] is reserved rather than resolved: [Load]'s
qualifier comes from the alias in an [import] form and from nowhere else,
so refusing that one alias ([Load.reserved_alias]) is the whole of keeping
this prefix unambiguous.
It is legal when nothing is shadowed, too. A spelling that only compiles
while some other declaration exists is a spelling nobody can write down in
advance, and the point of an escape hatch is that it is always there. *)
let builtin_prefix = "builtin/"
let qualified_builtin n =
let p = String.length builtin_prefix in
if String.length n >= p && String.sub n 0 p = builtin_prefix then
Some (String.sub n p (String.length n - p))
else None
(* [builtin/] reached with something that is not a builtin's name. The
did-you-mean is over the builtins alone and not over the program's own
names: the reader wrote the qualifier, so they were reaching for a
compiler name, and offering them a defn called [lem] would be answering a
question they did not ask. Every other did-you-mean in this file keeps the
candidates it already had. *)
let not_a_builtin loc bare =
if bare = "" then
Loc.failk "check/unknown-builtin" loc
"%s needs a name after it — the qualifier reaches a builtin, as \
(%slen v)" builtin_prefix builtin_prefix
else
match nearest !builtin_names bare with
| Some m ->
Loc.failk "check/unknown-builtin" loc
"%s is not a builtin, so %s%s reaches nothing — did you mean %s%s?"
bare builtin_prefix bare builtin_prefix m
| None ->
Loc.failk "check/unknown-builtin" loc
"%s is not a builtin, so %s%s reaches nothing. The %s qualifier \
reaches the compiler's own names and nothing else; an ordinary \
function is called by the name it was defined under"
bare builtin_prefix bare builtin_prefix
(* What a [break] or a [continue] may be talking about, innermost first.
[Lloop] is a loop it is lexically inside, carrying its label if it was given
@ -3093,8 +3143,22 @@ and in_range loc k n =
(* The arms that are names rather than calls, and the same rule holds for them:
each is in [builtins] below, and test_flan reads this match to check it. *)
and var ctx loc ~want name =
and var ctx ?(qualified = false) loc ~want name =
match name with
(* The qualifier in a value position: [builtin/nil], [builtin/true],
[builtin/context/allocator] the last one falls out for free, since
stripping one prefix off it leaves a name this match has an arm for.
A qualified name that gets past these arms must be refused and not
handed on to the tables below, which is the whole difference between
this and the call path. Falling through would look up [len] in
[env.fns] and answer with the address of the very definition the reader
wrote [builtin/] to get away from the feature inverted, silently. So
the catch-all arm below asks [qualified] before it looks anything up. *)
| _ when not qualified && qualified_builtin name <> None ->
let bare = Option.get (qualified_builtin name) in
if not (Hashtbl.mem builtin_set bare) then not_a_builtin loc bare;
var ctx ~qualified:true loc ~want bare
| "true" | "false" ->
expect ctx loc ~want (mk loc Types.Bool (Tast.Bool (name = "true")))
(* The dyn absence value, written down. It arrived with maps — (get m k) on
@ -3129,6 +3193,18 @@ and var ctx loc ~want name =
| "context/temp" ->
expect ctx loc ~want
(mk loc Types.Alloc (Tast.Prim (Tast.Rt "flan_context_temp", [])))
| _ when qualified ->
(* In [builtin_set] — the arm above checked — but not one of the four
arms above this one, so it is a builtin that exists only as a call.
There is no value to hand back: a builtin is an arm in the compiler,
not a function in the program, so it has no address for a [Tast.FnAddr]
to carry. Bare [len] in this position says "unknown name"; this says
the true thing instead, which is that the name is real and the position
is wrong. *)
fail loc
"%s%s is the builtin %s, which is a call and not a value — a builtin \
has no address to pass. Write (%s%s ...) at the call, or wrap it in a \
defn to pass that" builtin_prefix name name builtin_prefix name
| _ ->
match lookup ctx name with
| Some b ->
@ -5408,10 +5484,27 @@ and vec_at ctx loc (target : Tast.expr) (idx : Ast.expr list) =
(* Every arm below is a name an editor can be asked about and no program ever
wrote down, so each one needs a line in [builtins] further down this file.
A new arm without an entry fails the build test_flan reads both. *)
and named_call ctx ~want loc name args =
and named_call ?(qualified = false) ctx ~want loc name args =
let prim p ty args = expect ctx loc ~want (mk loc ty (Tast.Prim (p, args))) in
match name with
(* The user's own definition, first — before every builtin arm below.
(* [builtin/len], before anything else including the shadowing guard below.
The prefix is stripped and the same dispatch runs again with [qualified],
which is the one thing the guard consults: a qualified call has said
which of the two it means, so there is nothing left for shadowing to
decide. Everything after this point sees the bare name, so an arity or a
type refusal on [(builtin/len 1 2)] reads exactly as it does on
[(len 1 2)] which is the point of the spelling, not a loss of detail.
Recursion rather than a flag threaded through the arms because there is
only one thing to skip. It cannot loop: the stripped name has no second
[builtin/] on it unless somebody wrote [builtin/builtin/len], which is
stripped once and then refused by name. *)
| _ when not qualified && qualified_builtin name <> None ->
let bare = Option.get (qualified_builtin name) in
if not (Hashtbl.mem builtin_set bare) then not_a_builtin loc bare;
named_call ~qualified:true ctx ~want loc bare args
(* The user's own definition, ahead of every builtin arm below — and behind
the qualifier above, which is the one spelling it does not take over.
Clojure's rule: a [(defn get ...)] takes the name over, and a call
written in the program that defines it reaches that definition rather
than the builtin it is named after. The defn site is warned about once
@ -5423,7 +5516,8 @@ and named_call ctx ~want loc name args =
another's: [shadows_builtin] answers false for a call in the prelude and
for a call in imported package code, which is the same visibility rule a
defn has everywhere else. *)
| _ when shadows_builtin ctx loc name -> ordinary_call ctx ~want loc name args
| _ when (not qualified) && shadows_builtin ctx loc name ->
ordinary_call ctx ~want loc name args
(* ── arithmetic and comparison ─────────────────────────────────── *)
| "+" | "-" | "*" | "/" ->
let p = match name with
@ -8127,6 +8221,13 @@ let () =
flag, because there is nothing to tune a program either renamed a
builtin or it did not, and the line is one line and rare.
The second half of the sentence names the way out. Until [builtin/] there
was none: a file that defined [len] had given the builtin [len] up for the
whole file, and a defn that meant to *wrap* it was unbounded recursion
instead. Saying so here is the cheapest place it can be said the reader
is being told the name was taken over, and the next thing they want to
know is what is left.
The prelude is skipped: its defns are the language's own and a collision
there is a compiler bug rather than news for whoever is compiling. So are
qualified names, for the reason [shadows_builtin]'s [visible] gives
@ -8143,7 +8244,8 @@ let shadowed_builtins (decls : Ast.decl list) : Loc.diag list =
(Loc.diag ~kind:"check/shadows-builtin" fn.Ast.nloc
(Printf.sprintf
"%s shadows the builtin %s — every call in this program now \
reaches your definition" fn.Ast.name fn.Ast.name))
reaches your definition the builtin stays reachable as %s%s"
fn.Ast.name fn.Ast.name builtin_prefix fn.Ast.name))
| _ -> None)
decls
@ -8212,6 +8314,24 @@ let collect env (decls : Ast.decl list) =
would otherwise be found by LLVM, as [redefinition of function
'@flan.item'], or not at all. A [defn item] and a [defvar item] are two
declarations of one name and are rejected here. *)
(* The qualifier is reserved on this side too. [(defn builtin/len ...)]
reads the reader treats [/] as an ordinary symbol character and would
otherwise land in [env.fns] under a name nothing can ever call, because
[named_call] strips the prefix before any table is consulted. A
declaration that can only ever be dead is refused where it is written
rather than left to be discovered. [Load] refuses the same qualifier from
the other direction, at an import's alias. *)
List.iter
(fun (d : Ast.decl) ->
match Ast.declared_name d with
| Some n when qualified_builtin n <> None ->
fail d.Ast.dloc
"%s cannot be declared: %s is a reserved qualifier, so a name \
spelled with it reaches the compiler's builtins and never a \
declaration nothing could call this one"
n builtin_prefix
| _ -> ())
decls;
let claimed = Hashtbl.create 64 in
List.iter
(fun (d : Ast.decl) ->

View File

@ -144,6 +144,20 @@ let entries dir suffix =
let qualify alias n = alias ^ "/" ^ n
(* The one alias this module will not hand out. [builtin/len] is the builtin
[len] no matter what a file has defined ([Check]'s [qualified_builtin]),
and that only stays true while nothing else can produce the qualifier
[builtin]. Every qualifier in a finished program comes from [qualify], and
every [qualify] takes its alias from an [import] form, so refusing it here
is the whole of the reservation there is no other door.
It is the *alias* that is reserved and not the directory. A package whose
directory happens to be called [builtin] imports fine under any other
name, because the importer chooses the alias and the directory never
becomes one: [(import b "vendor:builtin")] declares [b/] and collides with
nothing. *)
let reserved_alias = "builtin"
(* A data type's value is written [Value.Int], which the reader hands over as
one symbol with a dot in the middle of it. The half before the dot is a
top-level name the package declares; the half after it is a case, and a
@ -1042,6 +1056,18 @@ let real dir = try Unix.realpath dir with Unix.Unix_error _ -> dir
[Cimport] has generated the header's declarations, so a macro quasiquoting
[(BeginDrawing)] names [rl/BeginDrawing] like everything else. *)
let rec import ~seen ~open_ ~loc alias dir =
(* Before anything is read off the disk, and here rather than at the
top-level import list so that a package importing one under this alias is
refused too. *)
(* The directory is not named in the message on purpose: it has been
resolved to an absolute path by here, and the caret is already under the
import form, which carries the path the reader wrote. *)
if String.equal alias reserved_alias then
fail loc
"%s is a reserved qualifier and cannot be an import alias: %s/name \
always means the compiler's builtin, which is how a program reaches a \
builtin it has shadowed. Import this package under another alias"
reserved_alias reserved_alias;
let dir' = real dir in
(* Checked before [seen], because a cycle's second arrival is also a repeat
visit and [seen] would otherwise call it a diamond and say nothing. *)

View File

@ -0,0 +1,46 @@
;;;; builtin/name: the spelling that always reaches the builtin.
;;;;
;;;; Shadowing a builtin is legal and program-wide within the file that does
;;;; it (shadow-builtin.flan is that rule on its own). What it used to cost
;;;; was the builtin itself: a (defn len ...) had given up the builtin len for
;;;; the whole file, so a definition that meant to *wrap* the builtin was
;;;; unbounded recursion instead, and stack-overflowed at run time with
;;;; nothing for the compiler to object to.
;;;;
;;;; builtin/len is the way out, and it is the package qualifier's own
;;;; spelling: rl/draw-fps is draw-fps from the package imported as rl, and
;;;; builtin/len is len from the compiler. builtin is a reserved qualifier —
;;;; an import may not take it as an alias — so the two can never be confused
;;;; about which one a name means.
;;;;
;;;; Five lines, and between them the whole feature. In order:
;;;;
;;;; - 9: (builtin/max 3 9) with nothing in this program named max. The
;;;; qualifier is legal whether or not anything is shadowed; a spelling that
;;;; only compiled while some other declaration existed would be a spelling
;;;; nobody could write down in advance.
;;;; - 5: this file's own len, which is a real wrapper — it is "1 + the
;;;; builtin length", and the inner call reaches the builtin rather than
;;;; itself. This is the program the earlier lane could not write.
;;;; - 4: builtin/len in the same file, beside the bare name that means the
;;;; definition. Both spellings, one program, different answers.
;;;; - 99: the shadowed operator, unchanged. An operator is a builtin like any
;;;; other and shadows like any other.
;;;; - 3: builtin/+ — the reader takes builtin/+ as one symbol, because '/'
;;;; and '+' are both ordinary symbol characters and the token does not
;;;; begin with a digit or a sign, so an operator needs no exception here.
;;; The wrapper, written the way the dead end said could not be written: the
;;; inner builtin/len is the compiler's len, and the outer name is this one.
;;; Both additions are qualified too, since + is shadowed below and this
;;; function wants the arithmetic and not the 99.
(defn len [s string] i32 (builtin/+ 1 (builtin/len s)))
(defn + [a i32 b i32] i32 99)
(defn main [] ()
(println (builtin/max 3 9))
(println (len "abcd"))
(println (builtin/len "abcd"))
(println (+ 1 2))
(println (builtin/+ 1 2)))

View File

@ -0,0 +1,16 @@
;;;; The reserved qualifier, from the import's side. Refused, never built.
;;;;
;;;; builtin/len means the compiler's len and must go on meaning it, which is
;;;; only true while nothing else can produce the qualifier "builtin". Every
;;;; qualifier in a finished program comes from an import's alias, so refusing
;;;; that one alias is the whole of the reservation — there is no other door.
;;;;
;;;; It is the alias that is reserved and not the directory: the package this
;;;; names is imported under its own alias by shadow-builtin.flan and is fine
;;;; there. The importer chooses the qualifier, so the importer is where the
;;;; collision is.
(import builtin "pkgs/shadowed")
(defn main [] ()
(println (builtin/field {:a 1 :b 4})))

View File

@ -2547,6 +2547,20 @@ let () =
runs and its status is zero. *)
outputs "a defn shadows a builtin, and the package it imports does not"
"programs/shadow-builtin.flan" "7\n4\n99\n999\n4\n";
(* And the way back out of it. builtin/name is the builtin whatever the
file has decided the bare name means, so the two spellings sit side by
side in one program and answer differently: 9 is builtin/max with
nothing named max in the program at all, 5 is a len that is a real
wrapper 1 + the builtin length 4 is builtin/len beside it, 99 is
the shadowed operator and 3 is builtin/+.
The 5 is the line that could not be written before this. The same defn
without the qualifier is unbounded recursion: the inner call reaches
the definition being written, and the program stack-overflows with
nothing for the compiler to object to. The two warnings it earns are
on stderr and are pinned in test_flan.ml. *)
outputs "builtin/name reaches the builtin past a shadow"
"programs/builtin-qualified.flan" "9\n5\n4\n99\n3\n";
(* A data type crossing the same boundary, which was a refusal by name
until vendor/edn needed one. The rename has two halves and the second
is the one that is easy to do by accident only: the type's name is a
@ -2684,6 +2698,18 @@ let () =
refuses "a runaway instantiation names the chain"
"programs/generic-runaway.flan" "grow at ([2 i32])";
(* [builtin] as an import alias. The qualifier has to mean one thing, and
an import is the only thing in the language that can produce one, so
this is where the reservation is kept. Refused before the package is
read, and the same refusal covers a package importing one under that
alias, since every import goes through the same function. *)
refuses "a package imported under the reserved qualifier"
"programs/import-builtin-alias.flan"
"builtin is a reserved qualifier and cannot be an import alias";
refuses "and the refusal says what the qualifier is for"
"programs/import-builtin-alias.flan"
"how a program reaches a builtin it has shadowed";
(* The other half of the map deferral. The operations over a key that is a
type variable are deferred to the instantiation there is no hash and
no equality to emit until the key type is concrete and what makes

View File

@ -4474,7 +4474,8 @@ let () =
check "and the warning says what the name now means"
(d.Loc.dmsg
= "get shadows the builtin get — every call in this program now \
reaches your definition");
reaches your definition the builtin stays reachable as \
builtin/get");
check "and it carries no notes, being one sentence about one decision"
(d.Loc.notes = [])
| _ -> check "a shadowing defn is warned about exactly once" false);
@ -4496,7 +4497,8 @@ let () =
(d.Loc.kind = "check/shadows-builtin"
&& d.Loc.dmsg
= "+ shadows the builtin + — every call in this program now \
reaches your definition")
reaches your definition the builtin stays reachable as \
builtin/+")
| _ -> check "a shadowed operator warns exactly once" false);
(match checked plus_src with
| p ->
@ -4525,6 +4527,108 @@ let () =
check "a call in another file reaches the builtin, at the builtin's arity"
(contains d.Loc.dmsg "len takes 1 argument, given 2"));
(* ── builtin/, the reserved qualifier ──────────────────────────────
The escape from the dead end above: [builtin/len] is the builtin [len]
whatever the file has decided [len] means. Pinned from both ends
with a shadow in the way and with nothing in the way at all because a
spelling that only worked while some other declaration existed would be
one nobody could write down in advance. *)
accepts "builtin/ is legal with nothing shadowed"
"(defn f [] i32 (builtin/len \"abcd\"))";
(* The pair that says the two spellings part company. One declaration list,
two calls: the shadow takes two arguments and the builtin takes one, so
each call is refusable only under one of the two readings. The bare name
at two arguments checks, and the qualified one at two arguments is
measured against the builtin's arity. *)
accepts "the bare name reaches the shadowing defn"
"(defn len [a string b string] i32 999)\n\
(defn f [] i32 (len \"a\" \"b\"))";
rejects_check "and builtin/ beside it reaches the builtin"
"(defn len [a string b string] i32 999)\n\
(defn f [] i32 (builtin/len \"a\" \"b\"))"
~needle:"len takes 1 argument, given 2";
(* The program the earlier lane could not write: a shadowing defn that
*wraps* what it shadows. Without the qualifier the inner call reached
the definition being written and the program stack-overflowed at run
time; what is pinned here is that the body holds no call to [len] at
all, which is the difference between a wrapper and a loop. *)
(match checked "(defn len [s string] i32 (builtin/+ 1 (builtin/len s)))" with
| p ->
let recurs = ref false in
(match List.find_opt (fun (f : Tast.fn) -> f.Tast.name = "len") p.Tast.fns with
| Some f ->
List.iter
(Tast.walk (fun (e : Tast.expr) ->
match e.Tast.e with
| Tast.Call ("len", _) -> recurs := true
| _ -> ()))
f.Tast.body;
check "a shadowing defn wraps the builtin instead of recurring"
(not !recurs)
| None -> check "the wrapping defn is checked" false)
| exception Loc.Error d ->
check "a shadowing defn wraps the builtin instead of recurring" false;
print_endline d.Loc.dmsg);
(* An operator through the qualifier, which is the case a reader would
expect to need an exception and does not: the reader takes [builtin/+]
as one symbol, and the call lowers to the [Add] prim even with [+]
shadowed two lines above. *)
(match checked "(defn + [a i32 b i32] i32 99)\n\
(defn f [] i32 (builtin/+ 1 2))" with
| p ->
(match List.find_opt (fun (f : Tast.fn) -> f.Tast.name = "f") p.Tast.fns with
| Some { Tast.body = [ { Tast.e = Tast.Prim (Tast.Add, _); _ } ]; _ } -> ()
| _ -> check "builtin/+ is the operator and not the shadowing defn" false)
| exception _ ->
check "builtin/+ is the operator and not the shadowing defn" false);
(* The value arms reach the same way, and [builtin/context/allocator] falls
out of one strip rather than needing a rule of its own. *)
accepts "a builtin written as a name is qualified too"
"(defn f [] dyn builtin/nil)";
accepts "and so is a builtin whose own name carries a slash"
"(defn f [] Allocator builtin/context/allocator)";
(* A builtin in a value position has nothing to hand back — it is an arm in
the compiler and has no address and the refusal has to say so rather
than falling through to the function table, which holds the very
definition the qualifier was written to get away from. *)
rejects_check "a call-only builtin is refused as a value, not resolved"
"(defn len [s string] i32 1)\n(defn f [] () (println builtin/len))"
~needle:"builtin/len is the builtin len, which is a call and not a value";
(* The qualifier reaching nothing. Named as not a builtin, and the
did-you-mean is over the builtins alone. *)
(match diag_of "(defn f [] i32 (builtin/nosuch))" with
| Some d ->
check "builtin/ with no builtin behind it has its own kind"
(d.Loc.kind = "check/unknown-builtin");
check "and says what is wrong with it"
(d.Loc.dmsg
= "nosuch is not a builtin, so builtin/nosuch reaches nothing. The \
builtin/ qualifier reaches the compiler's own names and nothing \
else; an ordinary function is called by the name it was defined \
under")
| None -> check "builtin/nosuch is refused" false);
(match diag_of "(defn f [] i32 (builtin/lne \"ab\"))" with
| Some d ->
check "and a near miss is offered in the qualified spelling"
(d.Loc.dmsg
= "lne is not a builtin, so builtin/lne reaches nothing — did you \
mean builtin/len?")
| None -> check "builtin/lne is refused" false);
(* And the did-you-mean everywhere else is untouched: a bare typo is still
answered with a bare name, not with a qualifier nobody reached for. *)
rejects_check "an unqualified typo is not answered with builtin/"
"(defn f [] i32 (lne \"ab\"))" ~needle:"did you mean len?";
(* The reservation from the other side. [(defn builtin/len ...)] reads —
'/' is an ordinary symbol character and would land in the function
table under a name nothing can ever call, because the prefix is stripped
before any table is consulted. *)
(match diag_of "(defn builtin/len [s string] i32 1)" with
| Some d ->
check "a declaration cannot take the reserved qualifier"
(contains d.Loc.dmsg
"builtin/len cannot be declared: builtin/ is a reserved qualifier")
| None -> check "a declaration under builtin/ is refused" false);
(* and's last operand is the then arm and the sentinel carrying the previous
operand's location is the else arm, so with no expectation in hand the
mismatch was reported one operand early. FIX.org's accepted fix: blame