Skip to content

fix(semantic): reuse branch-narrowed types in expression inference#1057

Open
lewis6991 wants to merge 3 commits intoEmmyLuaLs:mainfrom
lewis6991:split/structural-query-dynamic-key-spike
Open

fix(semantic): reuse branch-narrowed types in expression inference#1057
lewis6991 wants to merge 3 commits intoEmmyLuaLs:mainfrom
lewis6991:split/structural-query-dynamic-key-spike

Conversation

@lewis6991
Copy link
Copy Markdown
Collaborator

Replaces #1044.

Flow narrowing sometimes has to evaluate another expression that uses a value narrowed earlier in the same branch. For example, after key = "foo", t[key] should be checked with key as "foo", not with its declared "foo"|"bar" type.

The old fallback avoided recursive flow analysis by evaluating those expressions without flow. That kept the engine from re-entering itself, but it also lost precision in assignment RHS expressions, equality checks, field guards, dynamic table keys, and guarded call or callee aliases.

This keeps the flow engine responsible for the ordering. It records work that depends on another expression, resolves the branch-local values first, then resumes the original expression with those values layered over the normal no-flow inputs. Static caches and declared/member types still apply, but temporary expression and call results do not leak into the normal no-flow caches. Values that cannot be evaluated safely without flow now return no answer instead of caching broad guesses.

Assignments are conservative. The RHS can still use branch-local values, but the previous branch-narrowed source for the assignment target is reused only when the new RHS is compatible with it. Otherwise the engine falls back to the type before the current guard so stale branch narrowing is dropped after overwrites.

The indexing changes share lookup data between normal index/table-field lookup and __index fallback. Exact literal keys stay exact, broader union/keyof/enum key types can expand to matching members, and broad string or number keys still include nil when they may miss. Lookup paths used for narrowing still avoid __index/operator fallback, so guards only narrow through members that are present directly.

The call changes apply the same conservative rule to guarded calls and callee aliases such as rawget and pcall. Non-string require paths and setmetatable calls without real __index/custom metatable shapes now decline in no-flow instead of guessing. Non-generic overloads keep candidates alive when an argument cannot be evaluated without flow, but such an argument cannot make an overload win unless the remaining candidates return the same type.

Regression tests cover branch-narrowed dynamic keys in assignments, initializers, comparisons, field truthiness, literal equality, stacked guards, call/callee aliases, member lookup, pcall, and ambiguous overload arguments. Existing assignment overwrite guardrails continue to cover stale narrowing after overwrite.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the type inference and flow analysis engine to support structural expression replay through a new "no-flow" mode and continuation-based dependency resolution. The changes include significant updates to the inference cache and the flow engine's walk logic. Feedback focuses on ensuring correct Lua semantics for assignments with missing values, which should result in nil rather than skipping the assignment. Other suggestions include treating declined expressions as Unknown to avoid stale types, optimizing the handling of cached condition actions, and addressing potential performance regressions caused by the removal of condition filters.

Comment thread crates/emmylua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs Outdated
Comment thread crates/emmylua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs Outdated
Comment thread crates/emmylua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs Outdated
Comment thread crates/emmylua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs Outdated
Comment thread crates/emmylua_code_analysis/src/semantic/infer/narrow/get_type_at_flow.rs Outdated
@lewis6991 lewis6991 force-pushed the split/structural-query-dynamic-key-spike branch from 6297472 to 86e4277 Compare May 1, 2026 11:07
@lewis6991
Copy link
Copy Markdown
Collaborator Author

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a 'no-flow' mode for expression inference, allowing the semantic model to resolve types without triggering full flow-aware analysis. This is achieved by adding expr_no_flow_cache and call_no_flow_cache to LuaInferCache, along with mechanisms to replay expression types using dependency overlays. The changes also include significant refactoring of the flow-aware narrowing logic, moving from a subquery-based approach to a continuation-based model for resolving dependencies. My feedback highlights that the current dependency collection logic in collect_expr_dependency_queries may miss narrowed member accesses; I have suggested tracking all trackable references, including index and call references, to ensure correct narrowing during replay.

Comment on lines +254 to +265
if matches!(expr, LuaExpr::NameExpr(_)) {
let flow_id = tree
.and_then(|tree| tree.get_flow_id(expr.get_syntax_id()))
.unwrap_or(fallback_flow_id);
if let Some(var_ref_id) = get_var_expr_var_ref_id(db, cache, expr.clone()) {
dependency_queries.push(FlowExprTypeQuery {
var_ref_id,
flow_id,
syntax_id: expr.get_syntax_id(),
});
}
return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The current implementation of collect_expr_dependency_queries only tracks NameExpr as dependencies for expression replay. This means that if a member access like t.x is narrowed by a guard, the replay mechanism will only resolve t with flow and then perform a no-flow lookup for x, losing the narrowing on t.x itself.

By using get_var_expr_var_ref_id for any expression, we can track all trackable references (including IndexRef and CallRef) as dependencies. The flow engine will then be responsible for resolving the entire chain of dependencies, ensuring that narrowed members are correctly handled during replay.

    if let Some(var_ref_id) = get_var_expr_var_ref_id(db, cache, expr.clone()) {
        let flow_id = tree
            .and_then(|tree| tree.get_flow_id(expr.get_syntax_id()))
            .unwrap_or(fallback_flow_id);
        dependency_queries.push(FlowExprTypeQuery {
            var_ref_id,
            flow_id,
            syntax_id: expr.get_syntax_id(),
        });
        return;
    }

lewis6991 added 2 commits May 1, 2026 14:01
Flow narrowing sometimes has to evaluate another expression that uses a
value narrowed earlier in the same branch. For example, after `key =
"foo"`, `t[key]` should be checked with `key` as `"foo"`, not with its
declared `"foo"|"bar"` type.

The old fallback avoided recursive flow analysis by evaluating those
expressions without flow. That kept the engine from re-entering itself,
but it also lost precision in assignment RHS expressions, equality
checks, field guards, dynamic table keys, and guarded call or callee
aliases.

This keeps the flow engine responsible for the ordering. It records work
that depends on another expression, resolves the branch-local values
first, then resumes the original expression with those values layered
over the normal no-flow inputs. Static caches and declared/member types
still apply, but temporary expression and call results do not leak into
the normal no-flow caches. Values that cannot be evaluated safely
without flow now return no answer instead of caching broad guesses.

Assignments are conservative. The RHS can still use branch-local values,
but the previous branch-narrowed source for the assignment target is
reused only when the new RHS is compatible with it. Otherwise the engine
falls back to the type before the current guard so stale branch
narrowing is dropped after overwrites.

The indexing changes share lookup data between normal index/table-field
lookup and `__index` fallback. Exact literal keys stay exact, broader
union/keyof/enum key types can expand to matching members, and broad
string or number keys still include nil when they may miss. Lookup paths
used for narrowing still avoid `__index`/operator fallback, so guards
only narrow through members that are present directly.

The call changes apply the same conservative rule to guarded calls and
callee aliases such as `rawget` and `pcall`. Non-string `require` paths
and `setmetatable` calls without real `__index`/custom metatable shapes
now decline in no-flow instead of guessing. Non-generic overloads keep
candidates alive when an argument cannot be evaluated without flow, but
such an argument cannot make an overload win unless the remaining
candidates return the same type.

Regression tests cover branch-narrowed dynamic keys in assignments,
initializers, comparisons, field truthiness, literal equality, stacked
guards, call/callee aliases, member lookup, `pcall`, and ambiguous
overload arguments. Existing assignment overwrite guardrails continue to
cover stale narrowing after overwrite.

Assisted-by: Codex
Use no-flow inference where loading only needs declared call signatures,
and avoid inferring semantic-token call prefixes that cannot use the
result. Move condition AST resolution behind cache misses so cached flow
walks do less repeated work.
@lewis6991 lewis6991 force-pushed the split/structural-query-dynamic-key-spike branch from 86e4277 to 3c6d2ca Compare May 1, 2026 13:01
Exact name and integer keys already have a direct member lookup. When
that misses, returning FieldNotFound avoids scanning every broad
compatible member and unioning unrelated signatures.

Assisted-by: Codex
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.

1 participant