Skip to content

fix(flow): scope inline type assertions to their expressions - #1290

Closed
hgschmie wants to merge 2 commits into
EmmyLuaLs:mainfrom
hgschmie:fix/inline-as-expression-scope
Closed

hgschmie wants to merge 2 commits into
EmmyLuaLs:mainfrom
hgschmie:fix/inline-as-expression-scope

Conversation

@hgschmie

@hgschmie hgschmie commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

An inline assertion on a compound expression can replace the types of its children and unrelated later reads:

---@class Train
---@field id integer
---@class TrainData
---@field train Train
---@class FreeTrain
---@field train Train

---@param data TrainData
local function create(data)
    ---@type FreeTrain
    local result = { train = data.train } --[[@as FreeTrain]]
    return result
end

data.train should retain type Train, while the constructor/result should be FreeTrain. Flow-sensitive queries instead apply the enclosing assertion to data.train, which can produce a false assignment mismatch. The flow walker also collected every @as it passed as a cast on whichever declaration/member was being queried, contaminating unrelated later reads.

Resolve the assertion's attached expression and apply it only to that expression's syntax identity. A comment attached to a statement targets its last RHS value; a table-field comment targets the field value. Stop treating @as as an ambient declaration/member cast. Initializer and member-assignment inference explicitly retain assertions on their RHS, and name/parenthesized-expression queries apply the assertion locally. Named @cast behavior is unchanged; this fix is independent of PR #1285.

Twelve regressions cover constructor fields, call arguments, parenthesized operands, unrelated later reads, valid/invalid field assignments, member assignments, multiple RHS values, nested assertions, and return/closure expressions. They verify that the intended result keeps its cast type while its source and neighboring expressions keep their own types. Additional cases query declaration/member flow APIs directly, verify the cast flag for both @cast and @as, and ensure an assertion attached to a control-flow statement is ignored.

Validation: all 2,411 workspace tests with all features, workspace/all-target Clippy with warnings denied, and rustfmt pass. Eight of the nine regression tests fail on unchanged upstream main. Files without any cast nodes take the existing flow-tree flag fast path; no new general flow walk is introduced.

Review follow-up: FlowTree::has_tag_cast() indexes both TagCast and AsCast nodes; the new explicit test confirms that contract. The test helper traverses LuaExpr nodes, so parameter/local declaration nodes cannot match; it rejects duplicate expression text instead of silently choosing a read. The query audit covers initializer and closure declarations, RHS member assignments and return expressions. Existing explicit declaration annotations keep their precedence.

Behavior note: @as describes the attached expression, including a value assigned to a local/member. It no longer changes unrelated variables just because their flow passes the comment. Use a targeted @cast name Type when changing a variable's flow type is intended. The review follow-up changes comments/tests only; all twelve focused tests, Clippy and formatting checks pass. A direct LSP reproduction confirms the false assignment warnings disappear.

@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

Overview

This PR refactors how inline --[[@as T]] assertions are applied. Previously, @as casts were collected as flow effects and applied to any declaration/member whose flow path passed through the comment, causing over-broad casting (e.g., casting fields of a table literal, or arguments of a call). The new approach binds the assertion to its specific target expression via inline_as_target and applies it only when the queried expression matches.

Issues & Observations

1. inline_as_target fallback may mis-target expressions (flow.rs)

owner => LuaExpr::cast(owner.syntax().clone()),

The catch-all arm casts the owner AST node directly to LuaExpr. If the owner is a statement or non-expression node (e.g., LuaIfStat, LuaBlock), LuaExpr::cast returns None, silently dropping the assertion. This is likely acceptable, but the behavior is implicit. Consider documenting which owner kinds are expected, or explicitly returning None for non-expression owners to make intent clear.

2. LuaCallExprStat target ignores trailing comments on multi-call statements

LuaAst::LuaCallExprStat(stat) => stat.get_call_expr().map(LuaExpr::CallExpr),

A LuaCallExprStat holds a single call expression, so this is fine. But note the comment says "its target is the last RHS value" — for call statements there is no RHS list, so the comment is slightly misleading. Minor.

3. has_tag_cast() early-return changes semantics for include_tag_casts = true

if !tree.has_tag_cast() {
    return base;
}

This short-circuits both TagCast and AsCast handling. Verify has_tag_cast() returns true whenever either effect kind exists. If has_tag_cast only tracks FlowEffect::TagCast (the ---@cast path op) and not AsCast, then apply_inline_as_casts would incorrectly bail out when only an @as is present. This is the highest-risk item — please confirm the flag's definition covers both effect variants.

4. Removed collect_decl_as_cast / collect_member_as_cast — verify no regression for non-expression decl paths
The old code applied @as casts during flow tracing for declarations and members. The new code only applies them via apply_inline_as_casts at expression query sites (type_of_expr_at, type_of_decl closure branch, trace_member value). Confirm that all previously-covered query paths now route through one of these entry points. In particular, trace_decl_uncached now has FlowNodeKind::AsCast(_) => {} — if a declaration's type is queried without going through type_of_decl's expression branch, the cast may be lost.

5. apply_inline_as_casts visibility is pub(super) but used across modules
It's called from types.rs (sibling module under semantic_model). pub(super) from flow.rs exposes it to semantic_model, which is correct. Just flagging for clarity — no action needed.

6. Test helper expression_type asserts exactly one match

assert_eq!(expressions.len(), 1, "expected exactly one expression: {text}");

This is brittle for texts like "input" that may appear multiple times (e.g., inline_as_return_and_closure_assertions_still_apply defines input once, but inline_as_nested_assertions_keep_their_own_targets uses input once — OK). However, in inline_as_does_not_cast_unrelated_later_reads, "other" appears as both a parameter and a local read. Verify the filter expr.get_text() == text doesn't match the parameter declaration node. If it does, these tests will fail intermittently. Consider filtering by position or excluding declaration sites.

7. Behavior change: @as no longer propagates through flow to later reads
This is the intended fix (see inline_as_does_not_cast_unrelated_later_reads), but it's a semantic change that could break existing user code relying on the old (arguably buggy) behavior. Worth a changelog/release note.

Recommendations

  • Verify has_tag_cast() semantics (item 3) — add a test or comment confirming it covers AsCast effects.
  • Audit all type_of_decl / type_of_member entry points (item 4) to ensure no path bypasses the new cast application.
  • Harden expression_type test helper (item 6) to avoid false matches on declaration nodes.
  • Clarify the LuaCallExprStat comment (item 2) since there's no RHS list for call statements.
  • Consider adding a regression test for a @as on a statement whose owner is not one of the explicitly handled kinds, to lock in the fallback behavior.

Overall the refactor is well-motivated and the test coverage is thorough. The main risk is the has_tag_cast() short-circuit and ensuring no query path lost cast application after removing the flow-trace-based collection.

@hgschmie
hgschmie marked this pull request as ready for review October 13, 2026 18:08
@hgschmie

Copy link
Copy Markdown
Contributor Author

Closing as superseded by #1288, merged in a0ce151 (implementation commit 8fab12a).

All 12 regression tests from this PR pass on that upstream implementation, so our separate fix is no longer needed. Thank you!

@hgschmie hgschmie closed this Oct 14, 2026
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