Module system rework and typechecker/codegen bug fixes - #2510
Merged
Conversation
#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.
…able Refactor module system
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ref(name)inside a module (bug: func references inside an imported module emit an unmangled symbol, leaking a C compiler error #2474)ref(mod.func)as the function reference it isPerformance and Optimization Improvements
DeclEntry.gray_type(refactor(module-system): replace string-mangling AST merge with a real (module, name) symbol table #2485)resolved_declTests
Docs