From 5aedfb810ed8afbca3ef4a9b81d988dcb4d4c4df Mon Sep 17 00:00:00 2001 From: "Henning P. Schmiedehausen" Date: Tue, 13 Oct 2026 09:40:35 -0700 Subject: [PATCH 1/2] fix(flow): scope inline type assertions to their expressions --- .../src/semantic_model/flow.rs | 113 +++++---- .../infer/legacy_tests/inline_as_scope.rs | 240 ++++++++++++++++++ .../semantic_model/infer/legacy_tests/mod.rs | 1 + .../src/semantic_model/types.rs | 7 +- 4 files changed, 305 insertions(+), 56 deletions(-) create mode 100644 crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/inline_as_scope.rs diff --git a/crates/emmylua_code_analysis/src/semantic_model/flow.rs b/crates/emmylua_code_analysis/src/semantic_model/flow.rs index c76b063e6..2f976eeb9 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/flow.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/flow.rs @@ -16,8 +16,8 @@ use std::collections::HashSet; use std::rc::Rc; use emmylua_parser::{ - BinaryOperator, LuaAssignStat, LuaAstNode, LuaCallExprStat, LuaDocTagAs, LuaExpr, - LuaLiteralToken, UnaryOperator, + BinaryOperator, LuaAssignStat, LuaAst, LuaAstNode, LuaCallExprStat, LuaComment, LuaDocTagAs, + LuaExpr, LuaLiteralToken, UnaryOperator, }; use rowan::TextSize; @@ -440,7 +440,11 @@ pub fn type_of_expr_at( match &expr { LuaExpr::NameExpr(name_expr) => { if let Some(decl) = model.resolve_name(name_expr.get_position()) { - return model.type_of_decl_at(&decl, offset); + return apply_inline_as_casts( + model, + expr_syntax, + model.type_of_decl_at(&decl, offset), + ); } model.type_of_expr(expr_syntax) } @@ -619,7 +623,7 @@ pub fn type_of_expr_at( let result = binary_type(model, op_token.get_op(), &left_ty, &right_ty); apply_expr_casts(model, expr_syntax, result) } - _ => model.type_of_expr(expr_syntax), + _ => apply_inline_as_casts(model, expr_syntax, model.type_of_expr(expr_syntax)), } } @@ -1020,10 +1024,32 @@ fn apply_expr_casts( model: &SemanticModel, expr_syntax: emmylua_parser::LuaSyntaxId, base: LuaType, +) -> LuaType { + apply_bound_casts(model, expr_syntax, base, true) +} + +/// Apply only expression-local @as assertions, without importing statement casts +/// into ordinary declaration inference or name-expression queries. +pub(super) fn apply_inline_as_casts( + model: &SemanticModel, + expr_syntax: emmylua_parser::LuaSyntaxId, + base: LuaType, +) -> LuaType { + apply_bound_casts(model, expr_syntax, base, false) +} + +fn apply_bound_casts( + model: &SemanticModel, + expr_syntax: emmylua_parser::LuaSyntaxId, + base: LuaType, + include_tag_casts: bool, ) -> LuaType { let Some(tree) = model.flow_tree() else { return base; }; + if !tree.has_tag_cast() { + return base; + } // Inline casts may be bound to the expression itself or an ancestor node (e.g. an assignment statement), // so check both the expression and all ancestors' flow bindings. let mut flow_ids = Vec::new(); @@ -1046,7 +1072,7 @@ fn apply_expr_casts( for flow_id in flow_ids { for effect in tree.get_flow_effects(flow_id) { match effect { - FlowEffect::TagCast(cast_ptr) => { + FlowEffect::TagCast(cast_ptr) if include_tag_casts => { let Some(chunk) = model.chunk() else { continue; }; @@ -1084,7 +1110,10 @@ fn apply_expr_casts( let Some(as_tag) = as_ptr.to_node(&chunk) else { continue; }; - if let Some(doc_type) = as_tag.get_type() { + if inline_as_target(&as_tag) + .is_some_and(|target| target.get_syntax_id() == expr_syntax) + && let Some(doc_type) = as_tag.get_type() + { ty = model.doc_type_lua(doc_type.get_syntax_id()); } } @@ -1095,6 +1124,20 @@ fn apply_expr_casts( ty } +/// An inline assertion applies to its attached expression. When the parser +/// attaches a trailing comment to a statement, its target is the last RHS value. +fn inline_as_target(as_tag: &LuaDocTagAs) -> Option { + let owner = as_tag.get_parent::()?.get_owner()?; + match owner { + LuaAst::LuaLocalStat(stat) => stat.get_value_exprs().last(), + LuaAst::LuaAssignStat(stat) => stat.get_var_and_expr_list().1.last().cloned(), + LuaAst::LuaReturnStat(stat) => stat.get_expr_list().last(), + LuaAst::LuaCallExprStat(stat) => stat.get_call_expr().map(LuaExpr::CallExpr), + LuaAst::LuaTableField(field) => field.get_value_expr(), + owner => LuaExpr::cast(owner.syntax().clone()), + } +} + /// `---@cast` path operation: widen / remove / replace. #[derive(Clone)] enum CastOp { @@ -1465,11 +1508,9 @@ fn trace_decl_uncached( collect_decl_cast(model, decl, cast_ptr, path); } } - FlowNodeKind::AsCast(as_ptr) => { - if options.casts { - collect_decl_as_cast(model, decl, as_ptr, path); - } - } + // An @as assertion belongs to its expression, not every declaration + // whose flow path passes this comment. Assignment RHS queries apply it. + FlowNodeKind::AsCast(_) => {} // Backtracked to the start (e.g. function parameters with no DeclPosition node): use the declaration type as the base and apply path guards/casts. FlowNodeKind::Start | FlowNodeKind::Unreachable => { if matches!(node.kind, FlowNodeKind::Unreachable) && mode == TraceMode::MergeBranch @@ -1591,7 +1632,11 @@ fn trace_member( narrowings: Vec::new(), casts: path.casts.clone(), }; - let mut value_ty = model.type_of_expr(*value_syntax); + let mut value_ty = apply_inline_as_casts( + model, + *value_syntax, + model.type_of_expr(*value_syntax), + ); if let Some(annotation) = member_annotation_type(model, member) { if model.type_check(&annotation, &value_ty) { value_ty = widen_const_type(value_ty); @@ -1606,9 +1651,7 @@ fn trace_member( FlowNodeKind::TagCast(cast_ptr) => { collect_member_cast(model, member, cast_ptr, path); } - FlowNodeKind::AsCast(as_ptr) => { - collect_member_as_cast(model, member, as_ptr, path); - } + FlowNodeKind::AsCast(_) => {} FlowNodeKind::TrueCondition(cond_ptr) => { if let Some(narrowing) = narrow_member_condition(model, member, cond_ptr, true) { path.narrowings.push(narrowing); @@ -1876,46 +1919,6 @@ fn collect_member_cast( } } -/// Inline assertion `--[[@as T]]`: when matching the current decl's path, replace the path type directly with T. -fn collect_decl_as_cast( - model: &SemanticModel, - _decl: &SemanticId, - as_ptr: &emmylua_parser::LuaAstPtr, - path: &mut PathState, -) { - let Some(ty) = as_cast_type(model, as_ptr) else { - return; - }; - if !matches!(ty, LuaType::Unknown) { - path.casts.push(CastOp::Replace(ty)); - } -} - -/// Inline assertion `--[[@as T]]`: when matching the current member's path, replace the path type directly with T. -fn collect_member_as_cast( - model: &SemanticModel, - _member: &SemanticId, - as_ptr: &emmylua_parser::LuaAstPtr, - path: &mut PathState, -) { - let Some(ty) = as_cast_type(model, as_ptr) else { - return; - }; - if !matches!(ty, LuaType::Unknown) { - path.casts.push(CastOp::Replace(ty)); - } -} - -fn as_cast_type( - model: &SemanticModel, - as_ptr: &emmylua_parser::LuaAstPtr, -) -> Option { - let chunk = model.chunk()?; - let as_tag = as_ptr.to_node(&chunk)?; - let doc_type = as_tag.get_type()?; - Some(model.doc_type_lua(doc_type.get_syntax_id())) -} - /// RHS type of an assignment: if decl is a multi-return slot on this assignment, take the matching return type by `return_index`; /// otherwise fall back to ordinary expression inference. fn assigned_value_type( diff --git a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/inline_as_scope.rs b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/inline_as_scope.rs new file mode 100644 index 000000000..14a5e623c --- /dev/null +++ b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/inline_as_scope.rs @@ -0,0 +1,240 @@ +use emmylua_parser::{LuaAstNode, LuaExpr}; +use googletest::prelude::*; + +use crate::{DiagnosticCode, FileId, LuaType, VirtualWorkspace}; + +fn expression_type(ws: &VirtualWorkspace, file: FileId, text: &str) -> LuaType { + let model = ws.analysis.semantic_model(file); + let expressions: Vec<_> = model + .chunk() + .expect("chunk") + .descendants::() + .filter(|expr| expr.get_text() == text) + .collect(); + assert_eq!( + expressions.len(), + 1, + "expected exactly one expression: {text}" + ); + let expr = &expressions[0]; + model.type_of_expr_at(expr.get_syntax_id(), expr.get_position()) +} + +const RECORD_TYPES: &str = r#" +---@class Train +---@field id integer +---@class TrainData +---@field train Train +---@field surface string +---@class FreeTrain +---@field train Train +---@field surface string +"#; + +#[gtest] +fn inline_as_table_does_not_cast_its_fields() { + let mut ws = VirtualWorkspace::new(); + ws.def(RECORD_TYPES); + let file = ws.def(r#" + ---@param train_data TrainData + local function create(train_data) + local result = { train = train_data.train, surface = train_data.surface } --[[@as FreeTrain]] + return result + end + "#); + assert_that!( + expression_type(&ws, file, "train_data.train"), + eq(&ws.ty("Train")) + ); + assert_that!( + expression_type(&ws, file, "train_data.surface"), + eq(&LuaType::String) + ); + assert_that!( + expression_type( + &ws, + file, + "{ train = train_data.train, surface = train_data.surface }" + ), + eq(&ws.ty("FreeTrain")) + ); + assert_that!( + expression_type(&ws, file, "result"), + eq(&ws.ty("FreeTrain")) + ); +} + +#[gtest] +fn inline_as_call_does_not_cast_its_argument() { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + let file = ws.def( + r#" + ---@param input string + local function convert(input) + local result = tostring(input) --[[@as integer]] + return result + end + "#, + ); + assert_that!(expression_type(&ws, file, "input"), eq(&LuaType::String)); + assert_that!( + expression_type(&ws, file, "tostring(input)"), + eq(&LuaType::Integer) + ); + assert_that!(expression_type(&ws, file, "result"), eq(&LuaType::Integer)); +} + +#[gtest] +fn inline_as_parenthesized_binary_does_not_cast_its_operands() { + let mut ws = VirtualWorkspace::new(); + let file = ws.def( + r#" + ---@param left number + ---@param right number + local function convert(left, right) + local result = (left + right) --[[@as string]] + return result + end + "#, + ); + assert_that!(expression_type(&ws, file, "left"), eq(&LuaType::Number)); + assert_that!(expression_type(&ws, file, "right"), eq(&LuaType::Number)); + assert_that!( + expression_type(&ws, file, "left + right"), + eq(&LuaType::Number) + ); + assert_that!( + expression_type(&ws, file, "(left + right)"), + eq(&LuaType::String) + ); + assert_that!(expression_type(&ws, file, "result"), eq(&LuaType::String)); +} + +#[gtest] +fn inline_as_does_not_cast_unrelated_later_reads() { + let mut ws = VirtualWorkspace::new(); + let file = ws.def( + r#" + ---@class Record + ---@field text string + ---@param input string + ---@param other boolean + ---@param record Record + local function convert(input, other, record) + local adjusted = input --[[@as integer]] + local untouched = other + local member = record.text + return adjusted, untouched, member + end + "#, + ); + assert_that!(expression_type(&ws, file, "other"), eq(&LuaType::Boolean)); + assert_that!( + expression_type(&ws, file, "record.text"), + eq(&LuaType::String) + ); + assert_that!( + expression_type(&ws, file, "adjusted"), + eq(&LuaType::Integer) + ); +} + +#[gtest] +fn inline_as_table_assignment_checks_actual_field_types() { + for valid in [true, false] { + let mut ws = VirtualWorkspace::new_with_init_std_lib(); + ws.def(RECORD_TYPES); + let surface = if valid { "train_data.surface" } else { "123" }; + let no_diagnostic = ws.has_no_diagnostic(DiagnosticCode::AssignTypeMismatch, &format!(r#" + ---@param train_data TrainData + local function create(train_data) + ---@type FreeTrain + local free_train = {{ train = train_data.train, surface = {surface} }} --[[@as FreeTrain]] + return free_train + end + "#)); + assert_that!(no_diagnostic, eq(valid)); + } +} + +#[gtest] +fn inline_as_member_assignment_preserves_result_and_source_type() { + let mut ws = VirtualWorkspace::new(); + let file = ws.def( + r#" + ---@param input string + local function convert(input) + local holder = {} + holder.value = input --[[@as integer]] + local read = holder.value + local original = input + return read, original + end + "#, + ); + assert_that!(expression_type(&ws, file, "read"), eq(&LuaType::Integer)); + assert_that!(expression_type(&ws, file, "original"), eq(&LuaType::String)); +} + +#[gtest] +fn inline_as_multi_value_statement_only_casts_last_expression() { + let mut ws = VirtualWorkspace::new(); + let file = ws.def( + r#" + ---@param input string + ---@param other boolean + local function convert(input, other) + local first, second = input, other --[[@as integer]] + return first, second + end + "#, + ); + assert_that!(expression_type(&ws, file, "first"), eq(&LuaType::String)); + assert_that!(expression_type(&ws, file, "second"), eq(&LuaType::Integer)); +} + +#[gtest] +fn inline_as_nested_assertions_keep_their_own_targets() { + let mut ws = VirtualWorkspace::new(); + ws.def("---@class Container\n---@field value integer\n---@field other boolean"); + let file = ws.def( + r#" + ---@param input string + ---@param other boolean + local function convert(input, other) + local result = { + value = input --[[@as integer]], + other = other, + } --[[@as Container]] + return result + end + "#, + ); + assert_that!(expression_type(&ws, file, "input"), eq(&LuaType::Integer)); + assert_that!(expression_type(&ws, file, "other"), eq(&LuaType::Boolean)); + assert_that!( + expression_type(&ws, file, "result"), + eq(&ws.ty("Container")) + ); +} + +#[gtest] +fn inline_as_return_and_closure_assertions_still_apply() { + let mut ws = VirtualWorkspace::new(); + let file = ws.def( + r#" + ---@param input string + function convert(input) + return input --[[@as integer]] + end + "#, + ); + assert_that!(expression_type(&ws, file, "input"), eq(&LuaType::Integer)); + let file = ws.def( + r#" + local fn = function() return 1 end --[[@as string]] + return fn + "#, + ); + assert_that!(expression_type(&ws, file, "fn"), eq(&LuaType::String)); +} diff --git a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/mod.rs b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/mod.rs index d994de658..5c75f1069 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/mod.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/mod.rs @@ -19,6 +19,7 @@ mod generic_infer_test; mod generic_test; mod infer_str_tpl_test; mod inherit_type; +mod inline_as_scope; mod mathlib_test; mod member_infer_test; mod metatable_test; diff --git a/crates/emmylua_code_analysis/src/semantic_model/types.rs b/crates/emmylua_code_analysis/src/semantic_model/types.rs index 42e5cca53..a749634cc 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/types.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/types.rs @@ -220,7 +220,11 @@ impl<'db> SemanticModel<'db> { && let Some(closure) = LuaClosureExpr::cast(node) && let Some(fun) = self.type_of_signature(closure.get_syntax_id()) { - return Some(LuaType::DocFunction(Rc::new(fun))); + return Some(flow::apply_inline_as_casts( + self, + value_syntax, + LuaType::DocFunction(Rc::new(fun)), + )); } // Initializer expression: VM inference; re-entrant declaration/expression queries // return `None` through `CacheEntry::InProgress`. @@ -236,6 +240,7 @@ impl<'db> SemanticModel<'db> { } else { self.type_of_expr(value_syntax) }; + let ty = flow::apply_inline_as_casts(self, value_syntax, ty); if !matches!(ty, LuaType::Unknown) { // Multi-return assignment slot: the `b` in `local a, b = f()` takes f()'s 2nd return. // When a single-return function has no second slot, extra assignment targets stay Any (undeclared missing values), From 74d683da32689c3533c7af6e55da6f0366d10eb7 Mon Sep 17 00:00:00 2001 From: "Henning P. Schmiedehausen" Date: Tue, 13 Oct 2026 09:45:22 -0700 Subject: [PATCH 2/2] test(flow): verify inline assertion query boundaries --- .../src/semantic_model/flow.rs | 8 +- .../infer/legacy_tests/inline_as_scope.rs | 81 ++++++++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/crates/emmylua_code_analysis/src/semantic_model/flow.rs b/crates/emmylua_code_analysis/src/semantic_model/flow.rs index 2f976eeb9..5bfbfc7a5 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/flow.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/flow.rs @@ -1047,6 +1047,7 @@ fn apply_bound_casts( let Some(tree) = model.flow_tree() else { return base; }; + // FlowTree records both TagCast and AsCast nodes in this flag. if !tree.has_tag_cast() { return base; } @@ -1124,8 +1125,9 @@ fn apply_bound_casts( ty } -/// An inline assertion applies to its attached expression. When the parser -/// attaches a trailing comment to a statement, its target is the last RHS value. +/// An inline assertion applies to its attached expression. Local/assignment/ +/// return statements target their last value; call statements target their call. +/// A table-field owner targets its value. Other non-expression owners are ignored. fn inline_as_target(as_tag: &LuaDocTagAs) -> Option { let owner = as_tag.get_parent::()?.get_owner()?; match owner { @@ -1134,6 +1136,8 @@ fn inline_as_target(as_tag: &LuaDocTagAs) -> Option { LuaAst::LuaReturnStat(stat) => stat.get_expr_list().last(), LuaAst::LuaCallExprStat(stat) => stat.get_call_expr().map(LuaExpr::CallExpr), LuaAst::LuaTableField(field) => field.get_value_expr(), + // Expression owners are exact targets; blocks/control-flow statements + // have no asserted value and intentionally produce None. owner => LuaExpr::cast(owner.syntax().clone()), } } diff --git a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/inline_as_scope.rs b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/inline_as_scope.rs index 14a5e623c..19d8460ab 100644 --- a/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/inline_as_scope.rs +++ b/crates/emmylua_code_analysis/src/semantic_model/infer/legacy_tests/inline_as_scope.rs @@ -1,10 +1,12 @@ -use emmylua_parser::{LuaAstNode, LuaExpr}; +use emmylua_parser::{LuaAstNode, LuaAstToken, LuaExpr, LuaIndexExpr, LuaLocalName}; use googletest::prelude::*; use crate::{DiagnosticCode, FileId, LuaType, VirtualWorkspace}; fn expression_type(ws: &VirtualWorkspace, file: FileId, text: &str) -> LuaType { let model = ws.analysis.semantic_model(file); + // Parameters/local declarations are LuaParamName/LuaLocalName, not LuaExpr. + // Reject repeated expression text rather than selecting a shadowed read. let expressions: Vec<_> = model .chunk() .expect("chunk") @@ -238,3 +240,80 @@ fn inline_as_return_and_closure_assertions_still_apply() { ); assert_that!(expression_type(&ws, file, "fn"), eq(&LuaType::String)); } + +#[gtest] +fn inline_as_flow_flag_covers_both_assertion_kinds() { + for (source, expected) in [ + ("local value = 1", false), + ("local value = 1 --[[@as string]]", true), + ("local value = 1\n---@cast value string\nreturn value", true), + ] { + let mut ws = VirtualWorkspace::new(); + let file = ws.def(source); + let model = ws.analysis.semantic_model(file); + assert_that!( + model.flow_tree().expect("flow tree").has_tag_cast(), + eq(expected), + "{source}" + ); + } +} + +#[gtest] +fn inline_as_ignores_control_flow_statement_owners() { + let mut ws = VirtualWorkspace::new(); + let file = ws.def( + r#" + ---@param input string + local function convert(input) + do end --[[@as integer]] + local unchanged = input + return unchanged + end + "#, + ); + assert_that!(expression_type(&ws, file, "input"), eq(&LuaType::String)); + assert_that!( + expression_type(&ws, file, "unchanged"), + eq(&LuaType::String) + ); +} + +#[gtest] +fn inline_as_declaration_and_member_flow_queries_keep_assigned_assertions() { + let mut ws = VirtualWorkspace::new(); + let file = ws.def( + r#" + local value = "text" --[[@as integer]] + local holder = {} + holder.field = "text" --[[@as boolean]] + return value, holder.field + "#, + ); + let model = ws.analysis.semantic_model(file); + let chunk = model.chunk().expect("chunk"); + let local = chunk + .descendants::() + .find(|local| { + local + .get_name_token() + .is_some_and(|token| token.get_name_text() == "value") + }) + .expect("value declaration"); + let decl = model + .decl_by_offset(local.get_name_token().expect("name").get_position()) + .expect("decl"); + assert_that!(model.type_of_decl(&decl), eq(&Some(LuaType::Integer))); + let index = chunk + .descendants::() + .last() + .expect("member read"); + let member = model + .resolve_member(&index) + .and_then(|info| info.member_id) + .expect("member"); + assert_that!( + model.type_of_member_at(&member, index.get_position()), + eq(&LuaType::Boolean) + ); +}