Skip to content

fix(semantic): declared member type beats __index (#1319) - #1337

Open
mareklangiewicz wants to merge 3 commits into
EmmyLuaLs:mainfrom
mareklangiewicz:fix-metatable-index-declared-type-1319
Open

mareklangiewicz wants to merge 3 commits into
EmmyLuaLs:mainfrom
mareklangiewicz:fix-metatable-index-declared-type-1319

Conversation

@mareklangiewicz

@mareklangiewicz mareklangiewicz commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #1319. Neovim declares vim.env as ---@type table<string, string?> over setmetatable({}, { __index = function ... end }), and every if vim.env.X then was reported "always falsy". Three causes, all fixed:

 type_of_member_impl / shell member_type
-  call value's VM type wins; doc_type_syntax never read
+  a declared ---@type wins (as decl_type already does for locals)
 signature_returns
-  a return it cannot type (os.getenv(k)) is merged away → returns only nil
+  an unknown row makes the shell result unknown → VM return inference (string?)
 setmetatable builtin
-  types the __index closure → re-enters a narrowing in progress, caches truncated nil
+  only checks whether __index is a named type (metatable_index_is_closure)

Plus a separate first commit that now only adds a test: a ---@field [string] any class reported inject-field for g.aaa = true. After rebasing onto 1b6df85 the fix was no longer needed, because e1cd626 ("unify index signature resolution") replaced that check with applicable_index_infos. The test still pins the case.

Related: with this PR vim.env.X reads its declared table<string, string?>, but on main an index read of table<string, V> is unknown (#1334), so it shows unknown until that is fixed.

Evidence

  • Before (main 1b6df85 plus only this PR's tests): test_issue_1319_declared_type_wins_over_metatable_index, test_issue_1319_guarded_return_keeps_both_paths (unnecessary_if_test.rs) and test_member_declared_object_type_keeps_fields (member_infer_test.rs) fail. test_index_signature_class_allows_name_keys already passes on main.
    After: all pass, and so do the emmylua_code_analysis and emmylua_ls test suites.

Merge Danger

Door: two-way

Blast Radius: member type inference wherever a member has both a ---@type and a call value. This also affects return inference of functions with an untyped return row.

Written with help from Claude Code.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T88qMNChLeJksJGRszr7na

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 single return 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 Unknown should 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 ... end when 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 __index assigned 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_type behavior, 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_type vs. 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 the file_facts_of + member_by_id idiom. This improves readability and reduces the chance of inconsistent handling.

5. Test quality

  • test_issue_1319_guarded_return_keeps_both_paths asserts humanize_type(fun).ends_with("-> string?") — ends_with is brittle to formatting changes in humanize_type. Prefer exact equality or a structural type assertion if available.
  • The new tests reference issue #1319 in 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_keys is good and directly targets the starts_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.

mareklangiewicz and others added 3 commits October 31, 2026 15:38
```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
@mareklangiewicz
mareklangiewicz force-pushed the fix-metatable-index-declared-type-1319 branch from 1853fee to 0ffb918 Compare October 31, 2026 15:04
mareklangiewicz added a commit to mareklangiewicz/emmylua-analyzer-rust that referenced this pull request Oct 31, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A metatable __index function overrides a declared ---@type on main (Neovim's vim.env is "always falsy")

1 participant