Skip to content

Module system rework and typechecker/codegen bug fixes - #2510

Merged
SchoolyB merged 53 commits into
mainfrom
bugfixes/august-26-2026
Aug 26, 2026
Merged

Module system rework and typechecker/codegen bug fixes#2510
SchoolyB merged 53 commits into
mainfrom
bugfixes/august-26-2026

Conversation

@SchoolyB

@SchoolyB SchoolyB commented Aug 26, 2026

Copy link
Copy Markdown
Member

Summary

This PR contains the following changes: Rebuilds module resolution around a single (module, name) symbol table and clears several typechecker, codegen and module system bugs.

Bug Fixes

Performance and Optimization Improvements

Tests

Docs

  • Remove the rows make / free / size_of
  • Update STANDARD

SchoolyB and others added 30 commits August 23, 2026 15:35
#2459)

A bare call inside a struct function body now falls back to the enclosing
struct's namespace when no top-level function of that name exists, so a
struct function can call a sibling without the type prefix. The call label
is rewritten to the registered <Struct>_<func> name so codegen resolves it
exactly rather than guessing at the owning struct.

Top-level functions still win the bare name. A struct function that shares
a name with a top-level function is now rejected at the declaration with
the new E4022, which keeps the resolution order unambiguous in any program
that compiles.
… argument (#2461)

A concrete return type is a promise that has to hold for every caller.
Returning the caller's type argument breaks it for all but the one caller
that happens to pass a matching type, yet the contradiction was only ever
caught indirectly during monomorphisation — at a call site that instantiated
the function with a non-matching type, and not at all when every call site
matched or the function was never called.

The new E3139 checks the return statement during the normal body walk, so
the declaration is rejected on its own. It keys on what is returned, not on
the parameter list: a concrete return type stays legal whenever the body
returns a value of that type, so size_of(T) -> int is unaffected.
A struct-to-struct or enum-to-enum return mismatch was checked three times
over: a general types_assignable() test plus a display-name comparison per
kind, none of them guarding on another having already reported. One mistake
surfaced as two errors under the same code with two different wordings.

The three checks are now one reporting site. Detection is unchanged — either
test failing is still a mismatch, so the cross-module alias cases the name
comparison was added for keep working — and the message follows the wording
argument mismatches already use elsewhere in the checker, naming the kind for
struct and enum types.

The suite could not have caught this: fail tests assert that an error code
appears, never how many times. Adds an expect-error-count marker to
run_tests.sh and pins both cases at one error.
The final fallthrough of the module-qualified call chain resolved to void
without emitting anything. That made a typo indistinguishable from a genuine
void call: using the result drew a false E3038 blaming the callee's return
type, and discarding it reached the C compiler as a call to an undeclared
function. A module name that was never imported at all passed the same way.

Two new codes name the real problem instead — E4023 when the module is known
but has no such function, the module counterpart of E4018, and E6010 when the
name is not a module at all. Both suggest a near match: one suggester over
imported module names, one over a module's own functions, comparing the
suffix since they are registered as <mod>_<func>. A variable whose type never
resolved stays quiet, since whatever went wrong with it was already reported.

Adds diagnostic_error_code_formatted_help; the API had a formatted variant
and a help variant but no way to combine them.

Multi-file fail tests asserted nothing but a non-zero exit, so none of this
could have been caught there. The marker handling is now a shared
run_fail_test used by all three fail loops, and honours expect-error and
expect-error-count in fail/multi-file too. Tests without markers keep the
previous any-failure behaviour.
…ants (#2468)

private was enforced for module-level functions but not for variables or
constants: reading one through mod.NAME crossed the module boundary with no
diagnostic, compiled, and printed. STANDARD.md documents the constant case as
an error.

Two things were wrong. The import merge set is_private on every mutable
module-level variable regardless of the keyword, destroying the user's intent
before the typechecker ran — which is why const kept its flag and mut did not.
Its only consumer is E2002, gated on func_depth > 0, so it could never fire
for a top-level declaration. And the module-member lookup returned the symbol
without consulting privacy at all, since only the two call paths checked it.

The lookup now emits E4015, the same code the call paths use.

Repairs E6009_private_access_denied, which was written for exactly this case
but imported a path that does not exist, so it failed on E6002 without ever
reaching its subject. Renamed to match the code it actually asserts.
E6008 fired for any mod.member = x and stated the member was a constant
without checking. A user module's `mut` variable is not one, so the
diagnostic described the declaration incorrectly.

The noun is now a format argument read from the declaration: a mutable
top-level variable reads "variable", and a const or a member with no
declaration to consult — stdlib members such as math.PI — reads "constant".
main.gray imported ./somemod/internal/secret.gray while the file sat one
level deeper at somemod/internal/secret/secret.gray, so every run died on
E6002 without reaching what the test was written to check. It counted as
passing only because multi-file fail tests accepted any non-zero exit.

Moves the file to where the import path says it is, leaving the path itself
alone. With the import resolving there was no error left to expect, so the
test now asserts one it can: SECRET_VALUE is private, main.gray reads it, and
that is E4015 through a nested relative path. Renamed to match, and pinned
with expect-error markers so it cannot drift back into asserting nothing.
The user-module call branch resolved the FuncSig and stopped. It never bound
the wildcard, never validated a type argument, never recorded the
instantiation, and never substituted the return type — so mod.generic(int)
accepted a primitive as a struct type argument in silence, and mod.generic(T)
mangled a call to a specialization codegen was never told to emit, handing the
C compiler a call to a function that did not exist.

Rather than duplicate the bare-call path's logic, that block is extracted into
resolve_generic_call and shared by both. One implementation is what keeps the
two spellings reporting identically, which is the property the tests assert.

Codegen's member-call path needed the same two things its bare-call
counterpart already had: a binding derived from an is_type_param argument, and
erasure of type-parameter arguments, which were being emitted as values and
leaking the type name into the C output as a bare identifier.

Every case is tested in both spellings, qualified and bare, so they cannot
diverge again without a test failing.
…lver (#2485)

Introduces ModuleTable / ModuleScope / DeclEntry and the two resolvers that
every module-aware lookup will go through, so that module membership becomes
structural data instead of a naming convention encoded into identifiers.

Nothing calls into it yet — this stage only lands the structure. The
population pass, the typechecker call sites, and the codegen call sites move
over in the stages that follow, after which rewrite_labels and the four
codegen registries are deleted.

Notes on the shape it landed in:

- Entries are allocated individually from the compiler arena rather than out
  of a growing array, so a DeclEntry* cached on an AST node stays valid as
  the owning module keeps growing. The whole table dies with the arena, so
  there is no destroy path.
- scope.c's djb2 hash is now shared rather than reimplemented; both maps here
  are name -> index over a dense array, the same shape Scope already uses.
- resolve_qualified reports why a lookup failed (no module / no member /
  private), because the private-access diagnostics need that distinction
  once the visibility checks consolidate into it.
- resolve_unqualified treats a name found in two using'd modules as
  ambiguous rather than silently taking the first, matching what the flat
  arrays do today when a using clones a colliding name.
- module_mangle is the one place a module name and a declaration name become
  a C identifier.
…2485)

register_declarations now inserts every top-level declaration into its
module's scope under the name as written in source, alongside the existing
flat arrays. Nothing reads the table yet, so behavior is unchanged.

Module attribution comes from the file a declaration was written in, not from
its name. The import driver is the only thing that knows which module owns a
given .gray file, so main.c collects (file, module) pairs while resolving
imports and hands them to the type checker before the check runs. Attributing
by file is what makes a directory import land every sibling file's
declarations in one scope, and it works for aliases, which the import merge
never renamed and which therefore have no prefix to read a module out of.

Transitional: the merge still renames imported declarations, so the source
name is taken from the *_DISPLAY_NAME macros while the node still carries the
mangled one. The two coincide once the merge stops renaming.

Module-level constants get a registration pass of their own. They have no
flat-array registry today — they are ordinary scope symbols — but `mod.NAME`
has to resolve to them, so they are module members like anything else.

DeclEntry.gray_type is left NULL for now; it gets filled when the resolver is
first consulted for types.

Verified against the multi-file suite's fixtures that attribution is exact
and that module_mangle reproduces the names the compiler already emits:
directory imports merge their files into one scope, aliased imports key on
the alias (which is what the merge prefixes with today), private functions
and constants carry VIS_PRIVATE, and stdlib imports stay out of the table.

tests/test_module_table.c covers the structure and both resolvers directly:
26 cases over definition, hash growth past several rebuilds, file
attribution, visibility, aliases, using search order and ambiguity, mangling,
and the underscore case that suffix-guessing gets wrong.
…ble (#2485)

First stage that puts the resolver on live code paths. The lookups converted
here are the ones whose equivalence is provable: each one asked "is this
module-qualified, and what does it name" by rebuilding a mangled string, and
now asks the symbol table instead.

Aliases. The type checker's alias_names/alias_modules arrays are gone;
typechecker_resolve_alias delegates to the table, which register_decl_imports
now populates. Fifteen call sites keep working unchanged because they all go
through that one function.

Module variables and constants. find_module_var_decl scanned every top-level
statement in the merged program comparing against a rebuilt <mod>_<name>
string. It is now one hash lookup keyed by (module, name). It deliberately
ignores visibility: both callers want the declaration whether or not they may
touch it, and decide separately what to say about a private one.

Module functions. find_module_func replaces the resolve-alias/snprintf/
find_func sequence at five call sites: the two E3040 multi-return checks,
user-module call dispatch, `using` dispatch, and multi-return temp typing.
Sites that mangle something other than a module member — struct-namespaced
calls (Type_func), imported struct functions (mod_Struct_func) — are left
alone, since those are not module members and the table does not hold them.

Deleted a verbatim duplicate: the E3040 check on a call argument inside
resolve_call_expr was a second copy of reject_multi_return_in_single_position,
down to the diagnostic arguments. It calls the helper now.

Also fixes a latent divergence introduced by the previous stage. The entry
module was keyed by the entry file's basename, so importing a module whose
directory or filename matched — main.gray importing sub/main.gray — merged
the two into one scope, where the string mangling had kept them apart. The
entry module is now keyed under MODULE_ENTRY_NAME, which no import can
produce, and a qualifier can never name it.

module_mangle_into writes to a caller buffer so the lookup keys built on
every resolution do not each allocate from the arena.

Verified behavior-preserving by differential compile against the previous
commit over every .gray file under integration-tests: 1611 files, byte-
identical generated C and byte-identical diagnostics in all of them. The sole
reported difference was the GRAY_VERSION string embedded by
core/runtime_introspection.gray, which differs because the baseline was built
from a clean worktree.

test_module_table gains two cases for the entry-module keying: an imported
module may share the entry file's basename, and the entry module is not a
valid qualifier. 28 pass.
#2485)

Completes the type checker's half. Thirteen more sites that rebuilt a
<mod>_<name> string now call module_member_key, which asks the symbol table
where the declaration lives and spells the key from that. What is left in
typechecker.c is five Struct_func manglings, which are struct-function
prefixing rather than module membership and stay as they are.

module_member_key generalizes the previous stage's find_module_func, which is
now two lines on top of it. It only decides how a key is spelled — the flat
registries stay the authority on what a key names until they are replaced.
That is what makes each of these a like-for-like substitution rather than a
change of meaning.

Converted:
- qualified enum and struct access: mod.Enum.VARIANT in both call and
  member-expression position, and the mod.Type.member triple chain
- the four `using` searches: bare type names, function marking, module
  variables, and when-case enum detection
- module constant access through mod.NAME
- the sites where a qualifier may be either a module or a struct type —
  func references (bare, through ref(), and inside a [func] array literal),
  the initializer call-name path, and the #discard check. These land on the
  same key either way: the table answers for a module, and the fallback
  reproduces the old string exactly for a struct.

Two incidental fixes in code being touched anyway. The initializer call-name
buffer was `static`, which it has no reason to be. And the [func] array
literal branch xmalloc'd its entry while the branch beside it stored an
AST-owned pointer into the same array, so nothing could ever free it — both
are arena strings now.

The casing heuristics that guard these paths (`mod[0] is lowercase &&
member[0] is uppercase`) are deliberately still in place. Removing them
changes which programs resolve, so they come out with the bug fixes and their
regression tests, not here.

Verified behavior-preserving by differential compile against the previous
commit over every .gray file under integration-tests: 1745 files, byte-
identical generated C and diagnostics, with the sole difference again being
the GRAY_VERSION string in core/runtime_introspection.gray.
…le (#2485)

Codegen kept its own answer to "does this name belong to a module, and to
what", rebuilt from its own AST walk. It now shares the type checker's, handed
over next to the type table it already receives.

Deleted outright:

- The alias registry. codegen built alias_names/alias_modules from the same
  import items the type checker did, then insertion-sorted them so
  resolve_alias could bsearch. All of it is gone; resolve_alias is one call
  into the shared table.

- resolve_unprefixed_name's suffix guess. It matched a bare name against the
  text after the last underscore of every declared struct and enum, so any
  local type whose name contained an underscore could be claimed as a module
  member. It now reads the owning module from the symbol table.

- Two thirds of the is_module chain in member-expression emission. The
  comment there records that a prefix scan already caused this exact bug once
  (`item.priority` emitted as `item_priority` because `do item_is_alive` was
  in scope) and was patched by adding registries to consult. The table answers
  the question directly and supplies the mangled name at the same time, so the
  find_function probe and the alias scan are gone. The using and import lists
  remain as the fallback for stdlib modules, which are not in the table.

Verified behavior-preserving by differential compile against the previous
commit over every .gray file under integration-tests: 1745 files, byte-
identical generated C and diagnostics, sole difference the GRAY_VERSION string
in core/runtime_introspection.gray. Also built and ran the directory-import
and aliased-directory-import programs end to end; both pass.
Six multi-file cases, one per bug the symbol-table refactor is meant to
resolve. They fail today, each with the symptom its issue documents, and the
refactor's flip is what makes them pass. Committing them failing first makes
that step's effect legible.

- alias-cross-module (#2481): a type alias reached as mod.Alias. Today
  E3001, because the import merge never renames NODE_ALIAS_DECL and the
  alias is treated as an opaque struct type.
- cross-module-struct-func-inference (#2475): `mut x = mod.Struct.func()`.
  Today infers unknown, which truncates 2.5 to 2 and prints true as 1 with
  no diagnostic. Asserted through interpolation so the corrupted value is
  what fails, not just type_of.
- func-ref-in-module (#2474): `()name` written in a non-entry file. Today
  emits the unmangled symbol and leaks a C compiler error, plus a false
  W1003 on a function that is called.
- strict-when-qualified-enum (#2473): #strict over mod.Enum.VARIANT
  patterns. Today E3056 in both spellings — a tagged enum reports its
  payload-free variant missing, a plain enum is not seen as an enum.
- sibling-name-collision (#2472): a parameter, local, or struct-literal
  value whose name matches a sibling file of a directory module. Today
  rewritten to the directory module name, leaking a C compiler error.
- struct-default-sibling (#2470): a struct field default reading a sibling
  file's constant or enum variant. Today E4001, though the field's type
  resolves fine.

These are pass tests, so the runner only checks the exit status. Value
correctness is asserted with assert() rather than printed PASS/FAIL lines,
which would report a wrong answer and still exit 0 — the failure mode #2475
is about.
…#2485)

The struct, enum, and function registries were keyed by the name the import
merge had rewritten onto the AST node. They are now keyed by
module_mangle() of the symbol-table entry for that declaration — the module
name comes from where the declaration actually lives rather than from a
string the merge wrote.

This is the mangling-as-a-pure-function-of-a-resolved-declaration part of the
issue, and it is also the measurement that matters before the merge can be
removed: if the resolver's key ever disagreed with the merge's rename, this
is the commit where it would show. It does not. Differential compile against
the previous commit over every .gray file under integration-tests: 1760
files, byte-identical generated C and diagnostics, sole difference the
GRAY_VERSION string in core/runtime_introspection.gray.

The six regression tests still fail exactly as before; nothing here was meant
to move them.
…le (#2485)

The counterpart to the previous commit, one phase later. Function, struct,
and enum declarations were emitted under the name the import merge had
written onto the node; they are now emitted under module_mangle() of the
symbol-table entry that declared them.

Recovering the entry needs neither a name nor a file: the table keeps a
reverse index from declaration node to entry, so a phase holding the node has
the module. That is what lets codegen name a declaration correctly without
tracking which file it is emitting from, which it has never done.

Generic instantiations are left alone. mangle_generic_name manages its own
per-binding suffix and temporarily puts it on the node, so while a wildcard
binding is active the name in hand wins. Without that guard the module
mangling overwrote the type-argument mangling and every instantiation
collapsed onto one symbol — caught by the differential across the thirteen
wildcard and generics tests, which is what the differential is for.

Byte-identical because the merge still renames and the two spellings agree.
That is the point: with the type checker (previous commit) and codegen (this
one) both naming declarations from the table, removing the rename can only
change what the resolver and the rename disagree about.

Differential compile against the previous commit over every .gray file under
integration-tests: 1760 files, byte-identical generated C and diagnostics,
sole difference the GRAY_VERSION string in core/runtime_introspection.gray.
test_module_table covers the node index and its rehash: 30 pass.
…2485)

The flip. Names are no longer rewritten when imported files are merged; a
reference stays as the programmer wrote it and is resolved against the symbol
table, which is what both phases have been naming declarations from since the
two preceding commits.

Parser. read_type_name, the module-qualified struct literal, and the
when-pattern enum name keep the qualifier attached — lib.Score, not
lib_Score. Flattening it there produced a name with no way back to the module
it came from.

main.c. rewrite_labels and rewrite_type_name are deleted, along with the
declaration-name collection and the compound alias_Name -> mod_Name mapping
that fed them. rewrite_labels handled 27 of 45 node kinds and silently
dropped the rest, which is where several of the bugs below came from. Import
discovery, path resolution, dedup and E6001 collision detection are
untouched. Sibling imports inside a directory module — where types.gray names
a file, not a module — are now recorded as an alias of the directory module
instead of generating a mapping per declaration.

Resolution. The type checker resolves a written name through the module the
current file belongs to, then the modules it uses. It is applied inside
find_struct, find_func, find_enum_index and is_enum_name, so every existing
call site keeps working with the name as written. Registration order no
longer matters: declarations enter the symbol table in a pass of their own
before any type is resolved, and `using` is collected there too rather than
during the statement walk, because a struct field's default is checked while
declarations are still being registered.

Codegen. It tracks which module's file it is emitting and resolves names the
same way — labels, calls, func references, struct literals, new(), for_each
collections, and type names. Two more suffix guesses are gone: the
first-underscore match over declared functions, and the alias registry keyed
by unqualified name. Field defaults are emitted in their own struct's module,
not the caller's.

types.c. The dot-splitting, the _Uppercase guess and the hardcoded denylist
of stdlib opaque type names are deleted. What remains is one rule: anything
still carrying a dot is stdlib, which keeps its own registries.

Fixes #2481, #2475, #2474, #2473, #2472, #2470 — all six now pass as
regression tests. Suite goes from 508 pass / 7 fail to 513 / 2, and all 1052
fail-tests still reject with the expected code, including the harness check
that no diagnostic leaks a mangled name.

Two known failures are documented in the handoff rather than papered over:
struct-array-cross-file relies on a bare imported name resolving without
`using`, which STANDARD.md 8.2 says requires dot notation and which only
worked because the deleted rewrite pass rewrote the entry file too; and
W2011_named_return_unused, which already failed before this branch.
)

E4015 and E4021 were decided at four call sites, each reading a different
source: FuncSig.is_private for a function, a parallel is_private array plus a
file comparison for a type alias, and a scan of the program AST for a
variable or constant. They are now one rule in module_decl_visible(), applied
inside module_resolve_qualified() and reported by one helper — the kind of
the declaration only picks which code to emit.

Resolving that meant fixing what the rule compares. RESOLVE_PRIVATE was
testing the module, but the error text — and the tests — say a private
declaration is private to its *file*: two files merged into one directory
module are as much "outside" each other as two separate modules. The
declaration already records its origin file, so the check reads that.

The resolvers now take a ResolveScope rather than a growing list of context
parameters. It carries the module an unqualified name resolves against, the
file that decides visibility, and the using list. module_resolve_qualified
lost its module argument entirely: a qualifier names its own module, so the
only thing that parameter ever did was the visibility test.

Deleted with their last reader: TypeChecker.type_alias_files and
type_alias_is_private, which were write-only once the declaration became the
source of truth, and module_var_is_private.

RESOLVE_PRIVATE is consumed now. It was added three commits before anything
used it, which was the wrong order.

Suite unchanged at 513 pass / 2 fail and 1052 fail-tests rejecting with the
expected code. test_module_table is 40 cases, including one pinning that a
directory module's files do not see each other's private declarations.
…2485)

The last remnant of the is_module chain in member-expression emission was two
loops asking the same question of two lists. A user module is answered by the
symbol table above it, so what reaches here is the stdlib, which keeps its own
registries by design. Named for what it actually asks.

Suite unchanged: 513 pass / 2 fail, 1052 fail-tests rejecting as expected.
Written once, always NULL, read nowhere. The resolver returns the
declaration and callers take the type from the registry that holds it, so
the field never acquired a reader — the same speculative-field mistake as
RESOLVE_PRIVATE, which at least got one in the end.

Suite unchanged: 513 pass / 2 fail, 1052 fail-tests rejecting as expected.
test_module_table 40 pass.
The struct, function and enum registries each kept a sorted copy of their
names so a lookup could bsearch it, rebuilt whenever anything was registered.
Three indexes, three comparators, three "sorted_built" flags — all keyed by
the mangled string the refactor set out to stop treating as identity.

They are gone. A declaration now records where its details live
(DeclEntry.registry_index), so resolving a name yields the entry and the
entry yields the details directly. find_struct, find_func and find_enum_index
are three lines each and consult nothing but the symbol table.

Two things had to join the table for that to hold. Struct functions, which
are namespaced under their struct rather than their module, and the
compiler-provided and stdlib types registered without a source declaration —
both get an entry now, so no lookup falls outside the table. Where a lookup
holds an already-mangled name rather than one as written, the table answers
from a mangled-name index it owns, replacing the three it displaced.

Two bugs this surfaced, both fixed here. A name declared twice in one module
resolves to the first declaration, and the duplicate was repointing that
entry at its own registry slot. And a struct/enum collision is visible in the
symbol table before either registry holds both halves, so E4007 now asks the
table rather than asking two registries that are filled in different passes.

Suite unchanged: 513 pass / 2 fail, 1052 fail-tests rejecting with the
expected code, test_module_table 40 pass.
AstNode gains resolved_decl: the declaration a reference names, left there by
the type checker when it resolves it. Codegen reads it instead of resolving
the same name a second time, which is the last place the two phases were
doing the same work independently.

The field is a forward-declared pointer, so ast.h stays free of the symbol
table — the parser has no business knowing about it, and nodes are memset on
allocation, so anything unresolved is NULL by construction rather than by
convention.

Wired at the references where both phases need the answer: bare labels naming
a module-level declaration, struct literals, and func references. Where the
type checker left nothing, codegen resolves as before.

Suite unchanged: 513 pass / 2 fail, 1052 fail-tests rejecting with the
expected code, test_module_table 40 pass.
…2485)

A parameter or return type written bare resolves against the module the
function was declared in, but the forward-declaration loop never entered that
module — it emitted with whatever module context the previous pass had left
behind. The declaration and the definition then disagreed whenever two
modules declared the same type name:

    static GrayString gray_fn_colors_describe(GrayStruct_shapes_Point);
    static GrayString gray_fn_colors_describe(GrayStruct_colors_Point p) { ... }

Invisible with distinct type names, which is why the suite did not catch it.

Suite unchanged: 513 pass / 2 fail, 1052 fail-tests rejecting as expected.
resolve_unprefixed_name scanned every module and took the first that declared
the name. With one module declaring it that is the right answer by accident;
with two it is a coin flip. A `Color.CRIMSON` written inside the module that
declares Color was emitted as another module's GrayEnum_traffic_Color_CRIMSON,
which does not exist there.

The scope is asked first now, and the scan is left as the fallback for names
belonging to no module in scope — stdlib opaque types, which is what it was
kept for.

Suite unchanged: 513 pass / 2 fail, 1052 fail-tests rejecting as expected.
A stdlib module's functions and opaque types now enter the module table when
the module is imported, so `mod.name` resolves the same way whatever kind of
module it names. Gated on the import: an unimported stdlib module has to stay
unresolvable or the unknown-module and unused-import diagnostics lose their
basis.

The entries are marked external. They carry identity and visibility only —
the C name of a stdlib call comes from its emitter, which knows about type
suffixes and argument marshalling that no mangling rule could reproduce.

That let two guesses go. resolve_unprefixed_name scanned every module and
took the first that declared the name, kept only because stdlib types were
not in the table; it is now what the scope says, and the function is gone.
type_from_name's dot rule no longer means "assume stdlib".

Two more places were emitting without knowing which module they were in,
both invisible until two modules declared the same type name: struct bodies
(a bare field type resolved against whatever module ran last) and the
qualified-type branch of the C type mapping.

new() now resolves its type name the way an annotation does, so
`new(mod.T)` and `^mod.T` agree on what they name.

Suite unchanged: 513 pass / 2 fail, 1052 fail-tests rejecting with the
expected code, test_module_table 40 pass.
…he table

Stdlib constants join their module alongside its functions and opaque types,
so `math.PI` resolves the way every other module member does.

That replaces a chain of per-module string compares in resolve_member, one
branch per module, which decided a constant's type by asking which module the
qualifier named. The math branch returned float for *any* member, so a
misspelled constant quietly became a float. Now an unknown member resolves to
nothing, which the paths below it are free to report.

The constant table describes its own struct-typed entry rather than having
the reader hardcode that NIL_UUID means UUID, and both the qualified and the
`using` path read it through one mapper instead of two switch statements that
disagreed — the `using` one returned unknown where the qualified one returned
UUID.

The concrete stdlib struct types (HttpRequest, HttpResponse, UUID, Database,
Router) are declared by their own module now rather than being swept into the
entry file's scope by their registration.

514 pass / 1 fail, up from 513 / 2: multi-file/struct-array-cross-file was
failing on a bare name from an `import and use`, and now resolves. 1052
fail-tests reject with the expected code. test_module_table 40 pass.
func_display_name() fell back to FuncSig.name, the flattened registry key,
unless the declaration carried original_name. Merging an import no longer
renames anything, so original_name is never set and every diagnostic that
named a module function printed the mangled key: "function 'lib_add_two'
expects 2 argument(s)".

The declaration node's own .name is the name as written, which is what
FUNC_DISPLAY_NAME already reads for the deprecation warnings.

This also clears a false unused-import warning. A bare call through
`using` marks the module used by looking its member up under the module
prefix; that key was built from the display name, so it searched for
lib_lib_greet, found nothing, and left the import marked unused. Every
`import and use` reported W1002 against a module it was using.

The runner's existing guard rejects a leaked mod_Type on its shape alone,
but a mangled function name is indistinguishable from a plain snake_case
name, so tests can now pin one by name with an expect-not: marker.
W1003 passed the entry file as the diagnostic's location while def_line
numbers the file the function was declared in. An unused function in an
imported module was reported against whatever the entry file happened to
hold at that line, usually blank:

  warning[W1003]: function 'find_heaviest' is declared but never called
    --> main.gray:6:1
     |
   6 |
W1001 passed the entry file as the diagnostic's location while def_line
counts lines in the file holding the function body. An unused local in an
imported module was reported against whatever the entry file held at that
line — unrelated source, or nothing when the entry file was shorter.

A local cannot outlive the function it is declared in, so the function
declaration node names the file without a def_file on every Symbol.

W1003 was the other half of this and is already fixed.

The test asserts the location with an expect-not: marker, which the pass
runner now honours the way run_fail_test already did. It is the only
mechanism here that can pin a warning's text, since a pass test otherwise
only has to exit zero.
SchoolyB and others added 19 commits August 25, 2026 22:06
docs: remove obsolete mem.make, mem.free, and mem.size_of rows (#2477)
A 'mod.fn(...)' call took the callee's return type and nothing else — its
arguments were never checked, so a mismatch reached codegen and came back
as a C compiler error. Two modules' same-named structs are distinct types,
and the message qualifies both by module when the bare names collide.
…2474)

Codegen renames every declaration to its module-mangled spelling, so
looking a function up under the name as written missed it inside an
imported module: ref(name) fell through to the variable path and emitted
a variable's address instead of a function pointer, which the C compiler
rejected.
Naming a module member is using the module whether the name is called or
referenced. Only calls marked the import, so a file whose only use of an
import was '()mod.func' was warned that the import was never used.
ref(name) and ()name are one thing written two ways, but only the bare
form was recognized: a qualified argument was checked as a pointer to a
value, and 'mod.func' has none, so the reference came out a ^unknown that
could not be called. A qualified argument naming a function now becomes
the same func-reference node the other spelling parses to.
The bare and struct-namespaced spellings both check how many arguments a
call was given; the module-qualified one did not, so too few arguments
reached codegen and came back as a C compiler error. Defaulted parameters
are accounted for the same way the other two paths do it.
Two modules' same-named types are rejected for each other, but every
message printed both as the same bare word: 'cannot assign Color to
Color' told the reader nothing, and the array-initializer mismatch
printed the internal paint_Color instead. A declared type is now
qualified as mod.Name whenever a second declaration goes by that name,
which leaves every unambiguous message as it was.
…2491)

The import-used scan read the module prefix from the start of the type
name, so '[types.Item]' measured a prefix of '[types' and matched no
import; map and pointer types failed the same way. Container and pointer
forms are unwrapped first, and the prefix now splits on either separator
since the name arrives here as written, not mangled.
…it (#2480)

Line and column came from the import statement but the file came from the
entry file, so the caret landed on whatever that file had on the line — for
a transitive import, never the import that failed. The raw 'cannot open'
line printed a second copy of the diagnostic, and the module that failed to
load merged no declarations, so every reference to it was reported
undefined as well. One import failure now produces one error.
'mod.Thing' parsed as a member expression and was never normalized, so
every consumer re-derived what it meant: 44 sites across the typechecker
and codegen tested member.object->kind == NODE_LABEL to decide whether the
object was a module, a struct instance, an enum type or a pointer, and only
8 of them also handled the nested mod.X.y shape — each of those 8 added
separately, as its own bug fix.

A pass after import merge now walks every member expression whose qualifier
names a module, resolves it through the symbol table and leaves the
declaration on the node. Codegen reads that instead of rebuilding the name
with snprintf: the module-qualified constant, the lib.Color.RED enum access
and the mod.Struct.func triple chain all mangle from the resolved entry, so
an alias and an entry-module name come out right without a spelling
heuristic. find_module_var_decl is gone; the E6008 site reads the node.

The remaining tests were structural, not semantic — they asked whether the
object was a bare name before reading it. Those move behind three accessors
in ast.h (ast_member_qualifier, ast_member_base_qualifier for p^.f, and
ast_member_chain for mod.Type.member), which is the shape test living in
one place rather than at every site that needs the qualifier. The two
bare-label tests left in check_assign_stmt are correct as written: a struct
instance genuinely is a bare local.

Codegen's hand-rolled strchr(name, '.') qualified-name parsing is replaced
by module_split_qualified, which existed for this and had no callers.

No behavior change intended. Verified against the full integration suite.
Every E4016 site required the annotation to start with a capital, so a
lowercase name that resolved to nothing skipped the check outright. Nothing
downstream caught it either: `x zag`, `mut x zag`, `const x zag = 5`, a
parameter typed `zag` and a return type of `zag` all typechecked clean and
emitted `zag x = {0};`, so the user's first sign of trouble was a C
compiler error.

The casing was never the question — whether the name resolves is. The three
copies of the test become one predicate, which reports whenever an
annotation types as unknown and exempts the generic wildcard, whose type
comes from the call site that binds it rather than from the annotation.

This is the same-line remainder of #2455: that fix stopped a lone
identifier from swallowing the next line's token as its type, and a bare
identifier in final position already reported E4001. Two identifiers
written on one line are a real declaration, and reached codegen unchecked.
… func

An undefined type inside a container was never checked. [T], map[K:V] and ^T
resolve to TK_ARRAY, TK_MAP and TK_POINTER whatever their elements name, so
testing an annotation as a whole never looked inside one: `mut x [zag]`
typechecked clean and failed in the C compiler. The check now recurses to
the leaves and names the leaf that is undefined rather than the whole
spelling. Struct fields already recursed; both paths now share the one
decomposition, which splits ',' at bracket depth zero, so [map[string:int],3]
no longer mis-splits on the comma inside its element type.

Recursing exposed the reason the bare `func` had to be exempted: the builtin
table declared it TK_UNKNOWN, so an untyped function reference was modelled
as a name for nothing. Six sites had grown compensations for that — two
guards suppressing false E3010 on func-typed fields, a name test in string
interpolation, a carve-out in symbol registration, and the block explaining
that "func round-trips as TK_UNKNOWN". It is a TK_FUNCTION now and all six
are gone. The five places that compared function types by their encoded
name share one predicate, which knows the bare `func` names no signature and
therefore matches every one.

Correcting the kind surfaced a latent double free. The type pool inferred
name ownership from the kind — TK_ERROR and TK_UNKNOWN names were literals,
every other kind's was heap — so moving `func` between kinds turned its
teardown into free() on a string literal, and grayc aborted after a
successful compile. Pooled builtin names are copied on creation now, so
ownership is uniform and the kind list is deleted.

`f func = <non-function>` reported E3001 twice once the generic check could
see the type; the message naming the reference form suppresses the generic
one.
@github-actions github-actions Bot added documentation Improvements or additions to documentation parser Related to parsing and AST construction typechecker Related to type checking and validation module-system Related to imports, exports, and module loading tests Related to unit tests or test infrastructure error-messages Related to improving error messages grayc Related to the grayscale compiler core or its internal tooling labels Aug 26, 2026
@SchoolyB SchoolyB changed the title Module system rework and import/typechecker bug fixes Module system rework and typechecker/codegen bug fixes Aug 26, 2026
@SchoolyB
SchoolyB merged commit b9f32df into main Aug 26, 2026
@SchoolyB
SchoolyB deleted the bugfixes/august-26-2026 branch August 26, 2026 10:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation error-messages Related to improving error messages grayc Related to the grayscale compiler core or its internal tooling module-system Related to imports, exports, and module loading parser Related to parsing and AST construction tests Related to unit tests or test infrastructure typechecker Related to type checking and validation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants