Repository navigation
Conversation
Keep missing union members nullable through projection and argument checks. Preserve current recursive array/element assertions; update three test assertions to the intended nullable diagnostic and array-union contracts. Refresh UP-123 and UP-125 onto current main. Original contribution history and per-fix evidence are retained in the private maintenance inventory. Signed-off-by: Henning P. Schmiedehausen <henning@schmiedehausen.org>
There was a problem hiding this comment.
AI-generated review by OpenRouter Review.
Verify findings before applying changes. Feedback · Report to GitHub.
Code Review
Summary
The change removes the special-case member_missing_in_union diagnostic from param_type_check.rs and instead bakes "missing union member" semantics into the type system: member_info_impl now expands aliases before lookup and appends LuaType::Nil when a union arm lacks the accessed member. flow.rs and hover handling were updated accordingly. Tests are extensive.
Overall direction is good — moving the logic into type resolution avoids duplicated policy in the checker. However, several correctness/robustness concerns should be addressed before/after merging.
Significant Concerns
1. Unbounded recursion risk in the new alias fast-path (member.rs)
if alias_id
.and_then(|id| model.type_def_of(&id))
.is_some_and(|def| def.kind == TypeDefKind::Alias)
{
return with_expanded_alias(model, prefix_type, |expanded| {
member_info(model, expanded, key)
})
.flatten();
}This unconditionally expands and recurses before any other resolution. A self-referential alias such as ---@alias A A|B would re-enter member_info(A, key) unless with_expanded_alias has an explicit cycle guard and returns something that terminates the recursion. The recursive_alias_member_queries_keep_concrete_alternatives test suggests a guard exists, but please confirm the case where expansion is an identity (e.g. ---@alias Alias Alias or ---@alias A A). If not guarded, this will stack-overflow for user-supplied alias definitions.
Recommendation: verify with_expanded_alias short-circuits on identity expansion, and add a regression test for a self-referential alias that resolves to itself.
2. Exponential blow-up potential in the union absence scan (member.rs)
let component_info = member_info(model, component, key);This is called for every component of a union on every member query. If a union arm is itself an alias to another union, this causes the whole component tree to be re-walked (with nested alias expansion and nested union scans again). For deeply nested unions/aliases this is at least super-linear in the worst case.
Recommendation: consider flattening unions (or at least iterating only leaf components) before querying, and/or memoize per-(component, key) results. Even short-circuiting on the first absence would help.
3. is_complete(revision) heuristic is fragile (member.rs)
let revision = model.cache.borrow().revision();
let component_info = member_info(model, component, key);
let optional = ...;
optional || (component_info.is_none() && model.cache.borrow().is_complete(revision))This treats any None as "truly absent" only if no cache entries were added while resolving. Because unrelated lazy type resolution can bump the revision, the "missing member" nil may be omitted on first evaluation and re-added on a subsequent one, making the reported type non-deterministic across passes. Diagnostics (and hover) may flap depending on cache warmness.
Recommendation: use an explicit cycle/recursion guard flag (or return a "resolved vs. incomplete" indicator from member_info) rather than inferring from a global revision counter. Also add a test that first evaluates the type cold (no prior caching) and then warm, asserting identical results.
4. Diagnostic message regression (union_member_argument_test.rs)
assert_that!(diagnostics[0].message, contains_substring("`uint32?`"));The old message explicitly mentioned nil ("missing union member counts as nil"); the new message reports the surface type `uint32?`. This loses the reason for the failure. Users will find expected `integer`, got `uint32?` less actionable than the previous explanation.
Recommendation: keep surfacing the fact that a union arm lacks the member — e.g. augment mismatch_message to append "(missing member in one union alternative)".
5. flow.rs only checks expand_alias_head (one level) for union detection
matches!(
super::type_eval::expand_alias_head(model, &ty),
Some(LuaType::Union(_))
)If expand_alias_head resolves only one alias step, a chain like A = B, B = C|D will not be recognized as a union prefix here, so guarded_index_type narrowing will silently be skipped. If expand_alias_head iterates until non-alias, this is fine — please confirm and add a test:
---@alias Middle ProjectionPresent|ProjectionAbsent
---@alias Outer Middle
---@param event Outer6. flow.rs: perf and fallback interaction
For every LuaExpr::IndexExpr in every file, this now calls model.type_of_expr_at on the prefix and expand_alias_head. Then:
if union_prefix.is_none()
&& let Some(resolved) = model.resolve_member(index_expr)When union_prefix.is_some() but member_type returns None (e.g. key genuinely absent), resolve_member is skipped entirely. If downstream code relied on member_id from resolve_member (documentation, method resolution, etc.), that path is now bypassed for union-typed prefixes. Please double-check the fall-through below the diff handles all union_prefix = Some cases, or fall back to resolve_member when member_type returns None.
Minor Issues
7. Test placement outside the mod test block (param_type_check_test.rs)
}
}
+#[test]
+fn test_nullable_union_projection_argument_contracts() {The new tests sit outside the existing mod test { ... }, unlike the rest of the file. They still compile, but this is inconsistent and makes it easy to miss gating (#[cfg(test)]) conventions if the file changes. Move them inside mod test.
8. Confusing local name
let accepts = ws.has_no_diagnostic(DiagnosticCode::ParamTypeMismatch, &source);
...
assert!(!accepts, "{annotation}");accepts actually stores "no diagnostics", and the assertion negates it. Rename to no_diagnostics (or assert positively).
9. Repeated imports inside tests
Every new test repeats use crate::{DiagnosticCode, VirtualWorkspace}; (and others). Prefer hoisting these to the module top (or to the mod test header) for readability.
10. Unconditional origin_typ computation in hover (build_hover.rs)
let origin_typ = semantic_type_of(&origin_model, &origin);This runs even when &origin == decl or origin_is_param, where the result isn't used. Likely cached, but a cheap let origin_typ = ... inside the else branch would avoid the unnecessary work and keep the intended invariant obvious.
11. member_missing_in_union possibly dead code
Since the checker no longer calls semantic_model.member_missing_in_union(index_expr), verify the method is either still used or removed. If unused, delete it to prevent future readers from trusting an outdated path.
Positive Notes
- Moving "missing union member ⇒ nil" into type resolution is the right architectural direction; it ensures all consumers (hover,
type_of_expr, checker) see a consistent type. - The new tests are thoughtful: they cover plain unions, aliases, generics, guarded narrowing, optional fields, unresolved nominals, and copy-then-hover projection. The
test_copied_union_member_hover_keeps_projected_nilaround declaration-vs-use-site hover is particularly valuable. - The check
!matches!(&origin_typ, Function | DocFunction | Signature)in the projected-member decision correctly avoids new nil-ness bleeding into call signatures. - Using
guarded_index_typewhen the prefix is a union preserves narrowing forif event.player_index then accept(event.player_index) end, which is the subtle case that would previously have required the removed isinstance fallback.
Recommended Test Additions
- Chained alias (
A = B,B = C|D) to lock in #5. - Self-referential alias (
---@alias A A|B) to lock in #1. - Cold vs. warm cache evaluating the same expression twice to verify #3.
- Union with a
Refto an unloadable type def (to exercise theunresolved_nominalbranch) — assert nil is not appended. - Union with a
LuaType::Nilarm and a member lookup — verify no nil is double-added and thatNeedCheckNilis not spuriously reported.
|
Assessed every point in review 5721585190 on exact head
The additional probe passed for self-cycle, chained union aliases, unknown nominal alternatives and nil arms, with repeated queries agreeing; it was restored cleanly. Existing exact-source validation remains 24 focused and 2,702 workspace tests passed (31 ignored), plus formatting and all-target/all-feature Clippy. I sign off this head for final user review; approval remains separate. |
Signed-off-by: Henning P. Schmiedehausen <henning@schmiedehausen.org>
|
Revisited the five recommended test additions from review 5721585190. NEW signed commit
All five tests pass on the existing production implementation; this commit adds regression coverage only. All 2,707 workspace tests pass (31 ignored), along with formatting and all-target/all-feature Clippy. My earlier assessment verified behavior with disposable probes but did not provide the requested lasting coverage; this follow-up closes that gap. The PR remains in draft for review of this new commit; final USER approval stays separate. |
There was a problem hiding this comment.
AI-generated review by OpenRouter Review.
Verify findings before applying changes. Feedback · Report to GitHub.
Code Review
The change fixes a real semantic gap: member access on a union (A|C where only A has handle) now yields T? rather than silently projecting T. The old ad-hoc member_missing_in_union check in param_type_check.rs is replaced by making the type itself nullable, which is the right layering. Overall the direction is good, tests are extensive, and the inline comments explain the non-obvious parts. Below are the issues worth addressing.
High priority
1. member_missing_in_union is likely dead code now
The only consumer of semantic_model.member_missing_in_union(...) was the deleted block in param_type_check.rs. If it isn't used elsewhere it should be removed, otherwise you will get a dead_code warning (or silently leave a stale helper). Worth grepping.
2. Behavioral change in a very central function (member_info_impl)
member_info_impl is used by completion, hover, signature help, flow, and diagnostics. Injecting Nil into the returned info.typ for all union member lookups is a broad change. Only two pre-existing tests were updated (union_member_argument_test.rs), but consumers that previously relied on the non-null member type may subtly change behavior. Consider:
- Auditing callers of
member_type/member_infofor code paths that assume non-null. - Adding a regression test for signature help and completion on
event.player_indexfor a union receiver.
3. Alias branch in member_info_impl swallows failures
if alias_id...is_some_and(|def| def.kind == TypeDefKind::Alias) {
return with_expanded_alias(model, prefix_type, |expanded| {
member_info(model, expanded, key)
})
.flatten();
}Once an alias head is detected, the function always returns, even if with_expanded_alias returns None (e.g. cycle-limits, unresolvable alias body). That means the earlier fast path (direct_member_info) and later fallbacks are bypassed for aliases. The SelfCycle test asserts None, so it is intentional; but for a truly unresolvable alias you lose the previous fallback. Please confirm this is desired and add a test for a missing (undeclared) alias body.
4. Cycle safety for self-referential generic aliases is untested
---@alias Gen<T> Gen<T> recursing through Generic(get_base_type_id()) is not covered by self_alias_member_projection_terminates_without_false_absence (which only tests nominal self-aliases). If with_expanded_alias keys on LuaTypeDeclId, this could stack overflow. Worth a test.
5. cache.is_complete(revision) is fragile
let revision = model.cache.borrow().revision();
let component_info = member_info(model, component, key);
optional || (component_info.is_none() && model.cache.borrow().is_complete(revision))This is the "cycle-limited query is unresolved, not absent" heuristic. Two concerns:
- If
is_completereturnsfalsefor any reason other than a cycle (e.g. pending derivation elsewhere in the project), a genuinely missing alternative silently stops contributingNil→ under-detection. - The revision is captured before
member_info, butmember_infomay push revisions; behavior depends onis_completesemantics without seeing it here. This deserves a doc-comment onis_completeexplaining the invariant, or a more explicit "hit-cycle" flag on the return value.
Medium priority
6. resolve_member is now bypassed for any union prefix
if union_prefix.is_none() && let Some(resolved) = model.resolve_member(index_expr) { ... }If union_prefix.is_some() but member_type(prefix, &key) returns None, we skip resolve_member and fall out of this block entirely. Cases to consider:
- Numeric indexing into a union with an array arm, e.g.
---@param x (integer[]|nil); x[1]. Ifarray::last_element_typedoesn't unwrap the union, the union branch runs,member_typeon key1may or may not resolve for the array component, andresolve_memberis no longer consulted. - Aliases that expose a "raw" member without going through
member_type.
Please add a test for (integer[]|nil)[1] and for (Foo|Bar)[key] where key is a non-literal.
7. expand_alias_head on a direct union
union_prefix is only Some when expand_alias_head returns Some(Union). If expand_alias_head returns None for a non-alias direct union, then the direct-union case in test_nullable_union_projection_argument_contracts ("ProjectionPresent|ProjectionAbsent") would fall through to the old path, and the new test would fail for the ("integer", "integer", false) case. The tests presumably pass, so expand_alias_head must return Some(Union) for direct unions — but this dependency is implicit and worth a one-line comment.
8. Performance on hot paths
- The alias branch does a
type_def_oflookup on everyRef/Def/Genericmember query. - The union branch calls
member_info(model, component, key)for each non-nil, resolvable alternative on every lookup — O(N) extra work per union access. For large generated unions this can compound with caching misses. build_hover.rs:origin_typis now computed unconditionally (let origin_typ = semantic_type_of(&origin_model, &origin);) for every member hover, even if unused. Prefer computing lazily in theelsebranch.
9. RefCell borrow inner-loop
model.cache.borrow() is invoked inside the any() closure. It currently works because component_info is evaluated before the final is_complete check, but this is fragile — if file_facts_of or member_info is ever changed to hold a cache borrow across the call, this becomes a runtime BorrowMutError. Consider hoisting the revision once outside the closure (per iteration vs per member query) and forbidding cache.borrow() inside the callback.
10. Optional detection relies on SemanticId::Member shape
let Some(member_id @ SemanticId::Member(key)) = info.id.as_ref() else {
return false;
};If a component exposes its handle through a non-Member SemanticId (e.g. a synthesized/inherited member), the optional marker is silently ignored. Add an assertion or comment clarifying that inherited members are handled elsewhere.
Minor
11. Test file organization
param_type_check_test.rs has existing tests inside mod test { ... }, but the four new tests (test_nullable_union_projection_*) are appended at file scope. Functionally fine but inconsistent. Prefer keeping all tests inside mod test for readability.
12. Fragile hover assertion
test_copied_union_member_hover_keeps_projected_nil asserts the exact rendered string "(field) player_index: integer?\n". Consider matching with contains("player_index: integer?") or contains("integer?"), to avoid coupling to hover-format details.
13. Redundant find on member_infos_with_key
if let Some(mut info) = member_infos_with_key(model, prefix_type, key)
.into_iter()
.find(|info| &info.key == key)_with_key should already restrict to the key, so .find(...) is redundant. Harmless, but worth cleaning up if member_infos_with_key guarantees filtering.
14. Unknown interaction with Nil injection
info.typ = LuaType::from_vec(vec![info.typ, LuaType::Nil]);If info.typ is Unknown, the result may still be Unknown, which is the desired conservative behavior; but if from_vec normalizes differently, this could produce an odd union. Add a ("Unknown", ...) case to test_nullable_union_projection_keeps_optional_generic_and_unresolved_alternatives.
15. Diagnostics message change
The removal of the explicit member_missing_in_union check means the diagnostic no longer says "nil" but uint32?. This is more accurate given the new type, but it changes user-facing messages. If there are downstream docs/snapshots not updated, they should be checked.
Summary
The core direction is correct and well-tested. The main things to resolve before merging:
- Confirm/remove the now-dead
member_missing_in_union. - Double-check
is_completesemantics and theRefCellborrow pattern inside the union loop (#5, #9). - Verify
resolve_memberbypass doesn't regress array/union indexing (#6), including a test. - Audit consumers of
member_infosinceNilis now injected globally (#2). - Add cycle tests for generic self-aliases (#4).
Signed-off-by: Henning P. Schmiedehausen <henning@schmiedehausen.org>
|
Assessed all fifteen points in review 5724838879. NEW signed commit
All 14 focused projection/consumer tests pass (one explicitly ignored), all 2,712 workspace tests pass (32 ignored, including the newly recorded pre-existing limitation), and formatting plus all-target/all-feature Clippy pass. These receipts bind the exact source. The PR remains draft pending current-head review; final USER approval remains separate. |
There was a problem hiding this comment.
AI-generated review by OpenRouter Review.
Verify findings before applying changes. Feedback · Report to GitHub.
Review Summary
The change replaces a special‑cased member_missing_in_union check in param_type_check.rs with a more principled fix upstream: union member projections (event.field where only some arms declare field) now evaluate to T | nil, and the normal argument check reports the mismatch. This is cleaner and also fixes the hover/completion/signature-help paths. Tests are extensive and clearly structured. A few concerns below.
Potential issues / suggestions
-
member.rs– redundant work and per-iteration cache reads
The new union branch iterates every non‑nil component and callsmember_info(model, component, key)even thoughmember_infos_with_keyalready computed per‑component member info for the same key. This duplicates the work (and recursion), which can be noticeable for wide/deep unions. Alsomodel.cache.borrow().revision()is re‑read on every iteration.- Hoist the revision snapshot outside the loop.
- If feasible, reuse the per‑component results from
member_infos_with_keyinstead of callingmember_infoagain.
-
member.rs–optionaldetection only coversSemanticId::Member
member_by_id(...).is_nullableonly works wheninfo.idis a realSemanticId::Member. For synthetic members (intersection/generic instantiation, etc.) this returnsfalse. The comment notes this ("Synthetic members carry nullability directly in their projected type"), but there is no explicit test for an intersection arm that is optional. Consider either extending detection or adding a dedicated test/comment that intersection components are intentionally handled by the nullability already intyp. -
flow.rs–resolve_memberis now skipped for any union prefix
Theresolve_memberpath is gated byunion_prefix.is_none(). If a union prefix is detected butmember_typereturnsNone(e.g., member missing on all arms, or all arms unresolved),resolve_memberis no longer attempted and execution falls through to the general fallback. Please confirm this is desired (e.g., for method resolution on union-typed receivers) and that the fallback still produces the expected diagnostic rather than silently yieldingUnknown/nil. -
hover/build_hover.rs– heuristic forprojected_memberis fragile- It only applies when
declisSemanticId::Decl(_), so locals and params get projected rendering, but globals/other decl kinds won't. - Function/DocFunction/Signature are explicitly excluded, so an optional callable member (e.g.,
round? fun(): integer) hovered via a union will not show the projected?. This may be a display regression worth a comment/test. - It depends on
info.typalready holding the projected value; if a decl re‑annotates its type, the projected display could be inconsistent. Consider resolving the projected type explicitly from the member origin rather than relying oninfo.typ.
- It only applies when
-
#[ignore]test – please add a tracking issue reference and, if possible, a rough plan for the recursive‑generic + inline‑object arm limitation so it isn’t forgotten. -
type_eval/tests.rs– the updated expectations (String | Nilfor array arms) confirm thatmember_typeandmember_infosintentionally diverge (nil projection applied only inmember_type). Worth a brief doc comment onmember_typeexplaining that union projections may insertnil, since callers may otherwise be surprised.
Minor
- The
is_completedoc comment is a helpful clarification; no action needed. - The removed special case in
param_type_check.rsalso previously fired even when the parameter acceptednil(e.g.,integer?); the new behavior correctly only reports real mismatches. Good improvement, and thetest_nullable_union_projection_argument_contractsmatrix covers it.
Overall the direction is sound and well tested; the main things to address are the redundant recomputation in the union loop and the robustness/documentation of the hover heuristic and resolve_member gating.
Signed-off-by: Henning P. Schmiedehausen <henning@schmiedehausen.org>
|
Assessed all six points in review 5724903773. NEW signed commit
The new intersection test and the existing projection regressions pass. Formatting, all-target/all-feature Clippy and full all-feature workspace validation pass; the recursive-generic inline-object case remains explicitly ignored. The PR remains draft for current-head review; USER approval is separate. |
There was a problem hiding this comment.
AI-generated review by OpenRouter Review.
Verify findings before applying changes. Feedback · Report to GitHub.
Code Review
Summary
The change reworks union-member projection: instead of a bespoke ParamTypeMismatch in the argument checker, it produces a properly projected member type (adding nil for absent/optional arms) in member_info_impl, wires it through flow.rs, and adapts hover/completion/signature help. The approach is reasonable and the tests are extensive, but there are several concerns.
Issues
1. Likely dead code: member_missing_in_union
param_type_check.rs removed the only call site of semantic_model.member_missing_in_union(index_expr). Unless it is used elsewhere, this now becomes dead code and should be removed (or have its remaining callers confirmed). The removal is correct behaviourally, but stray dead code should be cleaned up in the same PR.
2. Test placement / style inconsistency
In crates/emmylua_code_analysis/src/check/test/param_type_check_test.rs, the diff appends the new tests after the closing } of mod test:
}
#[test]
fn test_nullable_union_projection_argument_contracts() { ... }The rest of the file uses #[gtest] functions inside mod test { ... }. The new tests live at file scope, use #[test], and import use crate::{...}; inside the function bodies. This is inconsistent with the surrounding code and bypasses the module's helpers. Consider moving them into mod test (or documenting why they diverge). The same applies to the new mod union_callable_projection / mod union_projection_review_regressions in member.rs which sit outside the existing mod tests.
3. Performance: linear recursive re-query per union arm
In member_info_impl:
if let LuaType::Union(union) = prefix_type
&& union.iter().any(|component| {
...
let component_info = member_info(model, component, key);
...
})Every union lookup now (a) calls expand_alias_head and (b) recursively calls member_info for each arm. For deeply nested unions, aliases, or generic unions this multiplies the cost significantly, and the results don't appear to be memoized at this level. The is_complete(revision) guard prevents false negatives but doesn't prevent the repeated re-computation. Consider caching per-(component, key) results or short-circuiting once all arms have been evaluated.
4. Unknown | Nil union may cause downstream regressions
The test union_projection_unknown_field_remains_conservative asserts that UnknownField | Empty yields Unknown | Nil. Semantically Unknown already absorbs everything, so Unknown | Nil is redundant and could confuse downstream assignability checks (some checkers treat Unknown as an opaque top that behaves differently when wrapped in a union). Either normalize the projection to Unknown when the present type is Unknown/Any, or add a comment explaining why the redundant union must be preserved.
5. Hover heuristic can misfire for nullable functions / unions containing functions
In build_hover.rs:
let projected_member = matches!(decl, SemanticId::Decl(_))
&& matches!(origin, SemanticId::Member(_))
&& !matches!(
&origin_typ,
LuaType::Function | LuaType::DocFunction(_) | LuaType::Signature(_)
)
&& !matches!(&info.typ, LuaType::Unknown | LuaType::Any);- If the member is declared as
fun(): X?or(fun(): X) | nil,origin_typis aUnion, so theFunctionguard fails and projection kicks in withnil– probably desirable, but this contradicts the intent of excluding callables from projection. Consider matching on the union's inner type or documenting the intent. - A
Refto a named function type also bypasses the guard.
6. effective_typ_override silently ignored when projected_value is true
let effective_typ = if projected_value {
typ.clone()
} else if let Some(override_ty) = effective_typ_override {
override_tyIf a call-site substitution or runtime override previously carried information the projected type lacks, it is now discarded for any member reached from a data alias. This seems intended, but the interaction isn’t documented; a short comment (or narrowed override handling) would prevent future regressions.
7. is_complete reliance in the union arm check
The new logic relies on the fact that "successful cache insertions do not advance the revision" (per the new comment in cache.rs) to distinguish "absent because unresolved/recursive" from "absent because it truly doesn't exist". This is subtle and fragile: any future change that bumps the revision on successful inserts would silently disable the nil injection for every union arm. The new doc comment helps, but consider making the intent explicit at the call-site too (e.g. a named absent_is_proven(...) helper).
8. The #[ignore] test documents an unresolved case
#[ignore = "Known upstream limitation: recursive generic unions with inline object arms"]
fn recursive_generic_inline_object_arm_retains_its_member() { ... }This is honest, but it means the fix is incomplete for recursive generic unions with inline object arms. Worth tracking (or referencing the issue) so the limitation doesn't regress silently.
9. Minor: ws.def("") used as scratch file in a test
let file = ws.def("");
let cases = [ ... ].map(|source| ws.ty(source));Works, but creating an empty file just to obtain a model is a little opaque. A comment or a dedicated helper would make the test clearer.
Positives
- The removed special case in
param_type_check.rsis a genuine simplification; unify-once-into the projected type is the right design and the updatedunion_member_argument_test.rsexpectations (uint32?instead ofnil) confirm the message is now driven by the real type. - The alias routing at the top of
member_info_impl(with_expanded_alias) correctly handles chained aliases and generics, with tests coveringOuter/Middlechains and generic instantiations. - Cold/warm cache consistency is tested (
union_alias_projection_is_identical_on_cold_and_warm_expression_queries), and thenil-deduplication assertion innil_union_arm_preserves_member_type_and_receiver_diagnosticsguards against duplicatednil. - Letting individual union arms be “unresolved” rather than assuming absence (via
unresolved_nominaland theis_completeguard) avoids manufacturing false “missing member” diagnostics — a common source of false positives.
Signed-off-by: Henning P. Schmiedehausen <henning@schmiedehausen.org>
|
Assessed all nine points in review 5724958314. NEW signed commit
All projection regressions and all 2,713 all-feature workspace tests pass (32 ignored, including the explicitly tracked pre-existing limitation). Formatting and all-target/all-feature Clippy pass. The PR remains draft pending current-head review; USER approval remains separate. |
There was a problem hiding this comment.
AI-generated review by OpenRouter Review.
Verify findings before applying changes. Feedback · Report to GitHub.
Code Review
Summary of Changes
This PR reworks how union member projection is handled. Instead of a special-case check in param_type_check.rs that reported ParamTypeMismatch for missing union members, the type system now correctly projects X? (or X | nil) when any non-nil union arm is absent or optional. It touches the member projection layer, expression flow analysis, and hover rendering.
Overall the approach is sound and the test surface is extensive. A few concerns below, mostly around subtle correctness and maintainability.
Issues & Concerns
1. Over-reliance on cache revision semantics in member.rs (correctness / fragility)
let revision = model.cache.borrow().revision();
// ...
optional || (component_info.is_none() && model.cache.borrow().is_complete(revision))The projection correctness for "absent arm → add nil" hinges on the invariant documented in cache.rs:
Recursion and budget exhaustion advance the revision; successful cache insertions do not.
This is subtle and only documented in a one-line comment. If the invariant drifts (e.g., someone adds a cache path that records an incomplete result without bumping the revision), this logic silently over-reports absence. Consider:
- Adding a debug-only assertion test that cycle-limited member lookups of a union do not add
nil(partially covered byrecursive_generic_inline_object_arm_retains_its_memberbut#[ignore]d). - Documenting the invariant at the member-projection site as well, not only in
cache.rs.
2. expand_alias_head semantics for direct (non-alias) unions in flow.rs
matches!(
super::type_eval::expand_alias_head(model, &ty),
Some(LuaType::Union(_))
)It is unclear from this diff whether expand_alias_head returns Some(Union) when ty is already a LuaType::Union (not an alias). If it returns None for a plain union, then a direct union receiver will not take this path, and the new nil logic must be reachable through resolve_member. The test_nullable_union_projection_argument_contracts cases (e.g. "ProjectionPresent|ProjectionAbsent") imply the direct-union case works, but it is worth documenting this explicitly — a future change to expand_alias_head could silently regress direct-union nil projection.
3. union_prefix.is_some() short-circuits resolve_member
if union_prefix.is_none()
&& let Some(resolved) = model.resolve_member(index_expr)If union_prefix.is_some() but the member_type lookup yields None (e.g., no key, or no component exposes it), the code falls through and skips resolve_member. Verify that member_type's resolution paths fully cover what resolve_member used to handle for union prefixes (inherited members, metatables, native bindings, anonymous-table fallbacks). If member_type returns None on an otherwise resolvable loc, the expression will end up Unknown rather than the expected type. A brief comment stating "member_type is authoritative for union prefixes" would help future readers.
4. Alias expansion now runs on every member lookup
if alias_id
.and_then(|id| model.type_def_of(&id))
.is_some_and(|def| def.kind == TypeDefKind::Alias)
{
return with_expanded_alias(model, ...)
}This runs on member_info_impl for any Ref/Def/Generic whose def is an alias. For non-union aliases this is new behavior. The test_union_projection_array_and_dynamic_indexes_preserve_existing_paths and union_callable_projection_keeps_imported_closure_identity cases appear to cover it, but consider:
- A benchmark/measurement on the alias-targeting-member path (e.g.
require-returned modules aliased to tables), since this expands for every member lookup of a module. - Confirming there is no infinite recursion path when
with_expanded_aliasreturnsNone(the.flatten()path). Theself_alias_member_projection_terminates_without_false_absencetest exercises this, but only for a small handful of shapes.
5. Hover projected_member heuristic is brittle
let projected_member = matches!(decl, SemanticId::Decl(_))
&& matches!(origin, SemanticId::Member(_))
&& !matches!(&origin_typ,
LuaType::Function | LuaType::DocFunction(_) | LuaType::Signature(_))
&& !matches!(&info.typ, LuaType::Unknown | LuaType::Any);- An alias-to-function (
---@alias F fun(): integer) resolvesorigin_typto aLuaType::Ref, soprojected_memberwill betrueand the hover will fall through to the data renderer (no signature rendering). If aliases of function types are expected to render as signatures, this is a regression. - A union of function signatures also is not
LuaType::Function, so it will use the value contract — which is likely the desired behavior for the nullability fix, but means it will no longer render as a callable signature. Confirm this is intentional, and add a test forPresent|Absentwherehandle: fun(): integervs.handle: fun(): stringunion.
6. Tests: a few could assert stronger invariants
union_callable_projection_keeps_typed_alternatives_and_absenceuses.any(|f| f.get_ret() == &expected). This passes if there are duplicate callables or extra unexpected alternatives. Asserting a set (e.g. viaBTreeSetof return types) would be more robust.test_nullable_union_projection_retains_integer_and_nil_in_typeasserts the exact order[Integer, Nil]; that order is an implementation detail ofLuaType::from_vec. If ordering changes, this fails for a non-behavioral reason. Considerassert_eq!(as_set(...), ...)or add a comment noting the ordering is asserted on purpose.union_projection_array_and_dynamic_indexes_preserve_existing_pathsalso usesfor _ in 0..2rather than named cold/warm phases (as done in other tests), which reads less clearly — minor style.
7. Formatting / small nits
- Missing blank line between the last test of the previous module and the new
#[test] fn test_nullable_union_projection_argument_contracts(line}immediately followed by#[test]). Same for the transition intomod union_projection_review_regressions.cargo fmtmay want a blank line here. - Uses full crate path
crate::semantic_model::type_eval::expand_alias_headinline rather than importing. Minor style. SemanticLocalCache::is_completedoc comment says "No incomplete dependency has been observed since this snapshot." — "this snapshot" is ambiguous; it refers to therevisionargument. Suggest: "Returns true if no incomplete result was recorded sincerevisionwas captured."
8. Removed member_missing_in_union check: behavior change worth confirming
The old special case pushed ParamTypeMismatch regardless of the target param type:
message: mismatch_message(semantic_model, &LuaType::Nil, ¶m_ty, name),The new behavior routes through normal assignability. That means ---@param x any (or ---@param x integer?) no longer errors for a nil-possessing union member read — a real semantic improvement, and the tests now assert ("any", "integer", true) and ("integer?", "integer", true) accordingly. Worth calling out in the PR description, since this is a user-visible change beyond the original message text tweak in union_member_argument_test.rs. Also:
- The two
union_member_argument_test.rschanges assert the new message contains`uint32?`. That is derivable from the target type name, so the assertion is really "the message now names the declared param type rather thannil". Ifmismatch_message's format changes, these strings will break — consider asserting on a more stable piece (e.g. that the message contains both the target type name and does not containnil).
Minor / Opinion
- The nil-projection block inside
member_infos_with_keyis nested five levels deep (if let Some(...) { if let Union = ... && any(...) { ... } }). Consider extracting a helper likeunion_has_proven_absent_arm(model, union, key) -> boolfor readability. build_member_hovernow takes a fourth boolean-ish parameter, plus multiple internal!projected_valueconditionals. If this grows further, consider a smallHoverModeenum (ProjectedValue,Signature,Default) rather than a raw bool threaded through.- The
#[ignore]d testrecursive_generic_inline_object_arm_retains_its_memberis useful context, but if the underlying limitation is not scheduled, consider linking to an issue number in the ignore reason.
Verdict
The direction is correct and the coverage is good. The main risks are (a) the subtle cache-revision completeness check that determines nil projection, and (b) the assumption that member_type subsumes resolve_member for union prefixes. I recommend:
- Adding a brief comment (or test) pinning down the
expand_alias_head(Some(Union))behavior for plain unions. - Documenting the "skip
resolve_memberfor union prefix" decision inflow.rs. - Tightening the union-callable test to assert exact alternative count / set.
- Confirming the alias-to-function hover rendering behavior.
|
Developer assessment of review 5724985992 on exact head
The ignored recursive-generic inline-object case remains tracked with a baseline receipt and an investigation plan in the workspace finding; no public issue or scheduled fix is invented. Validation: 13 focused projection tests pass (one explicitly ignored), 2,713 all-feature workspace tests pass (32 ignored), formatting and all-target/all-feature Clippy pass. Developer signoff is complete for this head. Ready means awaiting separate USER approval; the active review history and maintained contribution remain unchanged until approval. |
|
approved |
Project missing or optional non-nil union members as nullable values through argument checks, flow, hover and callable consumers. For example, reading an integer field absent from another union arm now yields integer|nil: integer parameters reject it, while integer? and any parameters accept it.
Covers UP-123 and UP-125, refreshed onto
1b6df85942be5b76c6717b84ede386a4d48df38c. Follow-up regressions cover chained/recursive aliases, cold and warm queries, unresolved definitions, receiver nil checks, synthetic optional intersections, arrays, completion and signature help. Complete component projections are reused through the existing model cache.Current source validation: 2,713 all-feature workspace tests pass, with 32 ignored; formatting and all-target/all-feature Clippy pass. One newly retained ignored test records a baseline-proven recursive-generic inline-object limitation; that limitation is not claimed fixed.
Separate USER approval is required before signed squash, maintained-branch synchronization, closure or integration.