From 40d62e7643b6d303b8466f798afbae2e006073af Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 20:17:17 +0700 Subject: [PATCH] builtin/name, the spelling a shadow cannot take away A defn named after a builtin wins for its whole file, and until now that was the end of it: the builtin had no remaining spelling, so a defn that meant to wrap one was unbounded recursion. builtin/len is the builtin len wherever it is written, shadowed or not. The qualifier is the package one's, and builtin is reserved rather than resolved: Load refuses it as an import alias, Check refuses it as a declaration's name, and those two doors are the only ways a qualifier can be made. named_call and var each strip the prefix and re-enter with a flag that the shadowing guard consults, so every arm below sees the bare name and refuses in the builtin's own words. The shadow warning now names the escape in its second half. --- FIX.org | 179 +++++++++++++++++++++++- lib/check.ml | 130 ++++++++++++++++- lib/load.ml | 26 ++++ test/programs/builtin-qualified.flan | 46 ++++++ test/programs/import-builtin-alias.flan | 16 +++ test/test_acceptance.ml | 26 ++++ test/test_flan.ml | 108 +++++++++++++- 7 files changed, 523 insertions(+), 8 deletions(-) create mode 100644 test/programs/builtin-qualified.flan create mode 100644 test/programs/import-builtin-alias.flan diff --git a/FIX.org b/FIX.org index 97befb0..df96476 100644 --- a/FIX.org +++ b/FIX.org @@ -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 @@ -2897,3 +2908,169 @@ takes a location first, so the class name would have been read as a length. *~dune build~ does not compile ~flan_dyn.c~* — it is a string the compiler carries and hands to clang at ~flan run~ — so a green build is not evidence about that file at all. ~dune test~ is, and so is running any program. +* 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. diff --git a/lib/check.ml b/lib/check.ml index 3f7b21d..ae9fdb9 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -306,6 +306,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 @@ -3024,8 +3074,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 @@ -3060,6 +3124,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 -> @@ -5339,10 +5415,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 @@ -5354,7 +5447,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 @@ -7895,6 +7989,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 — @@ -7911,7 +8012,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 @@ -7980,6 +8082,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) -> diff --git a/lib/load.ml b/lib/load.ml index e56cbf8..28c0b9a 100644 --- a/lib/load.ml +++ b/lib/load.ml @@ -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. *) diff --git a/test/programs/builtin-qualified.flan b/test/programs/builtin-qualified.flan new file mode 100644 index 0000000..054ff7b --- /dev/null +++ b/test/programs/builtin-qualified.flan @@ -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))) diff --git a/test/programs/import-builtin-alias.flan b/test/programs/import-builtin-alias.flan new file mode 100644 index 0000000..97c2bce --- /dev/null +++ b/test/programs/import-builtin-alias.flan @@ -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}))) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 36d7393..20d9c89 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -2495,6 +2495,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 @@ -2632,6 +2646,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 diff --git a/test/test_flan.ml b/test/test_flan.ml index 8bb569a..af8dd33 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -4333,7 +4333,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); @@ -4355,7 +4356,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 -> @@ -4384,6 +4386,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