Repository navigation
fix(semantic): declared member type beats __index (#1319) - #1337
mareklangiewicz wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Code Review
Summary
The changes fix two related type-inference bugs: (1) index-signature detection now uses the is_index_signature flag instead of string-matching [, and (2) declared ---@type annotations on runtime members now take precedence over inferred value types, with a guard against __index closures causing reentrant inference. Tests are added for both.
Issues & Recommendations
1. signature_returns: unknown row poisons the entire result (potential over-correction)
crates/emmylua_code_analysis/src/semantic_db/query.rs
has_unknown_row |= row.is_unknown();
rows.merge(&row);
...
if has_unknown_row {
rows = TypeShell::unknown();
}- Concern: If any return statement has an untypeable expression, the whole function's return type becomes
Unknown, discarding otherwise-valid inferred returns. This is a broad behavioral change that could regress inference for functions with many returns where only one is unresolvable (e.g., a singlereturn some_std_call()among several well-typed returns). - Recommendation: Consider whether the fallback should be scoped to the specific arity/slot that is unknown, or whether
Unknownshould only be forced when the first return slot is unknown (since callers typically consume the first). At minimum, add a test covering a function with one unknown return among several known ones to document the intended behavior.
2. metatable_index_is_closure only handles TableConst literals
crates/emmylua_code_analysis/src/semantic_model/infer/vm.rs
let LuaType::TableConst(table) = metatable else {
return false;
};- Concern: The guard only detects
__index = function ... endwhen the metatable is a literal table. If the metatable is a variable (local mt = { __index = function... }) or a class instance, the reentrancy risk described in the comment still exists. This may be acceptable as a targeted fix, but the comment ("never gives the instance a named type") overstates the guarantee. - Recommendation: Either broaden detection (resolve the metatable type to its members) or soften the comment to clarify it only covers literal metatables. Also consider whether
__indexassigned a named function reference (not a closure) should be excluded too.
3. member_type: doc-type precedence may shadow legitimate value inference
crates/emmylua_code_analysis/src/semantic_db/query.rs
if !matches!(member.owner, SemanticId::TypeDef(_))
&& let Some(type_syntax) = member.doc_type_syntax
{
let shell = lower_doc_type(db, file, type_syntax, &[]);
if !shell.is_unknown() {
return shell;
}
}- Concern: This is consistent with
decl_typebehavior, which is good. However, the!shell.is_unknown()fallback means a malformed/unsupported doc type silently falls through to value inference — reasonable, but worth a test to lock in that behavior. - Minor: The comment says "as it does for a local declaration in
decl_type" — good for maintainers; consider linking the function name precisely (decl_typevs. the actual symbol) so it's greppable.
4. Duplicated member-lookup pattern
Both check_field.rs and vm.rs now use the same chain:
semantic_model
.file_facts_of(member_ref.file_id)
.and_then(|facts| facts.member_by_id(&member_ref.id))- Recommendation: Consider a helper on
SemanticModel(e.g.,member_def_of(&MemberRef)) to avoid repeating this boilerplate and to centralize thefile_facts_of+member_by_ididiom. This improves readability and reduces the chance of inconsistent handling.
5. Test quality
test_issue_1319_guarded_return_keeps_both_pathsassertshumanize_type(fun).ends_with("-> string?")—ends_withis brittle to formatting changes inhumanize_type. Prefer exact equality or a structural type assertion if available.- The new tests reference issue
#1319in comments but the PR description isn't shown; ensure the issue is linked in the PR body for traceability. test_index_signature_class_allows_name_keysis good and directly targets thestarts_with('[')→ flag change.
6. Behavioral note on is_index_signature
The switch from member.name.starts_with('[') to member.is_index_signature is a correctness improvement (the old check would also match a field literally named [foo] and miss index signatures whose name is the key type). No action needed, but confirm is_index_signature is populated for all index-signature forms (e.g., [integer], [string], and mapped aliases) — the is_mapped_alias branch remains separate, which is fine.
Verdict
The changes are well-targeted and well-commented, with tests. The main risk is item 1 (unknown-row poisoning) being broader than necessary. Items 2–4 are maintainability/robustness suggestions rather than blockers.
```lua ---@Class Accessor ---@field [string] any ---@type Accessor local g = {} g.aaa = true -- was: inject-field ``` Upstream e1cd626 ("unify index signature resolution") replaced the `member.name.starts_with('[')` skip in `check_index_assign` with an `applicable_index_infos` lookup, which accepts this; the test passes on 1b6df85 without a code change here. This commit only pins the case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01923KEUKXBrPZHHVvqPVpA1 Claude-Session: https://claude.ai/code/session_018NxUURS1MVzowfNDjH6GGy
…urns (EmmyLuaLs#1319) Neovim declares `vim.env` as ```lua --- @type table<string, string?> vim.env = setmetatable({}, { __index = function(_, k) local v = os.getenv(k) if v == nil then return nil end return v end }) ``` and every `if vim.env.X then` was reported "always falsy". Three causes: 1. A member's `---@type` was ignored. `type_of_member_impl` returned the VM type of a call value before looking at the doc type, and the shell `member_type` never read `doc_type_syntax`. Both now let a declared type win, as `decl_type` already does for locals. 2. `signature_returns` merged a `return` it could not type (the std call `os.getenv(k)` is unknown to the shell) away, so the function returned only `nil`. An unknown row now makes the shell result unknown and callers use the VM's return inference (`string?`). 3. Typing `v == nil` first (as unnecessary-if does) went `os.getenv(k)` -> param `k` -> the `setmetatable` call -> `member_info(mt, "__index")` -> the closure's return -> `return v`, whose narrowing was still in progress; the truncated `nil` was cached. The `setmetatable` builtin only needs to know whether `__index` is a named type, so it no longer types an `__index = function ... end` literal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01923KEUKXBrPZHHVvqPVpA1
Letting a member's `---@type` win (previous commit) read it through the shell,
which lowers a structural doc type to a bare `table`: `M.state = { waiting = 0 }
---@type { waiting: integer }` made `M.state.waiting` unknown, so
`M.state.waiting + 1` was `number` and assigning it back mismatched `integer`.
When the shell degrades a member's doc type to `table`/unknown, use the rich
projection, as a declaration's `---@type` already does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QkccaWeuDEcV9sJ8pLapCM
1853fee to
0ffb918
Compare
…mmyLuaLs#1340 (tree unchanged) The four PR branches were rebased onto upstream main 1b6df85 (b44c70c, 0ffb918, 9b307e9, 294d821). Their content is already here: the old heads were merged earlier and later fork commits reworked several of them, so a content merge only duplicated moved tests and re-conflicted reworked code. -s ours records the new heads as ancestors. The one substantive rebase change (EmmyLuaLs#1338 dropping expand_generic_alias) is ported separately in 0cb957b. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018NxUURS1MVzowfNDjH6GGy
Summary
Fixes #1319. Neovim declares
vim.envas---@type table<string, string?>oversetmetatable({}, { __index = function ... end }), and everyif vim.env.X thenwas reported "always falsy". Three causes, all fixed:Plus a separate first commit that now only adds a test: a
---@field [string] anyclass reportedinject-fieldforg.aaa = true. After rebasing onto 1b6df85 the fix was no longer needed, because e1cd626 ("unify index signature resolution") replaced that check withapplicable_index_infos. The test still pins the case.Related: with this PR
vim.env.Xreads its declaredtable<string, string?>, but on main an index read oftable<string, V>isunknown(#1334), so it showsunknownuntil that is fixed.Evidence
test_issue_1319_declared_type_wins_over_metatable_index,test_issue_1319_guarded_return_keeps_both_paths(unnecessary_if_test.rs) andtest_member_declared_object_type_keeps_fields(member_infer_test.rs) fail.test_index_signature_class_allows_name_keysalready passes on main.After: all pass, and so do the
emmylua_code_analysisandemmylua_lstest suites.Merge Danger
Door: two-way
Blast Radius: member type inference wherever a member has both a
---@typeand a call value. This also affects return inference of functions with an untypedreturnrow.Written with help from Claude Code.
🤖 Generated with Claude Code
https://claude.ai/code/session_01T88qMNChLeJksJGRszr7na