Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 136 additions & 42 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ ansi_term = "0.12.1"
num-traits = { version = "0.2", features = ["std"] }
mimalloc = { version = "0.1.50" }
googletest = "0.14.2"
ntest = "0.9.5"
unicode-general-category = "1.0.0"
luars = { version = "0.23.0", features = ["serde", "sandbox"] }
# request's default-tls feature pulls in a dependency on aws-lc-rs,
Expand Down
1 change: 1 addition & 0 deletions crates/emmylua_code_analysis/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ include = [
]
[dev-dependencies]
googletest.workspace = true
ntest.workspace = true

# Inherit workspace lints configuration
[lints]
Expand Down
33 changes: 33 additions & 0 deletions crates/emmylua_code_analysis/src/compilation/test/flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
mod test {
use crate::{DiagnosticCode, LuaType, VirtualWorkspace};
use emmylua_parser::{LuaAstToken, LuaLocalName};
use ntest::timeout;

const STACKED_TYPE_GUARDS: usize = 180;
const LARGE_LINEAR_ASSIGNMENT_STEPS: usize = 2048;
Expand Down Expand Up @@ -467,6 +468,38 @@ mod test {
assert_eq!(ws.humanize_type(after_assign), "integer");
}

#[test]
#[timeout(5000)]
fn test_issue_1094_self_call_fallback_stress() {
let mut ws = VirtualWorkspace::new();
let repeated_calls = (2..=30)
.map(|i| format!("if count == 0 then count = self:api{i}():api(code) end\n"))
.collect::<String>();
let block = format!(
r#"
function class(className, super)
end

local Test = class("Test")

function Test:api1(code, isBind)
local count = self:api():api(code)
{repeated_calls}
return count
end
"#
);

let file_id = ws.def(&block);
assert!(
ws.analysis
.compilation
.get_semantic_model(file_id)
.is_some(),
"expected semantic model for repeated self-call fallback stress repro"
);
}

#[test]
fn test_issue_1028_maxwellhome_like_large_array_builds_semantic_model() {
let mut ws = VirtualWorkspace::new();
Expand Down
24 changes: 22 additions & 2 deletions crates/emmylua_code_analysis/src/db_index/flow/flow_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,28 @@ impl FlowTree {
self.decl_bind_expr_ref.get(decl_id).cloned()
}

pub fn has_decl_multi_return_refs(&self, decl_id: &LuaDeclId) -> bool {
self.decl_multi_return_ref.contains_key(decl_id)
pub fn has_shared_multi_return_refs(
&self,
left_decl_id: &LuaDeclId,
right_decl_id: &LuaDeclId,
) -> bool {
let Some(left_refs) = self.decl_multi_return_ref.get(left_decl_id) else {
return false;
};
let Some(right_refs) = self.decl_multi_return_ref.get(right_decl_id) else {
return false;
};

left_refs
.iter()
.filter_map(|entry| entry.reference.as_ref())
.any(|left_ref| {
let left_call_id = left_ref.call_expr.get_syntax_id();
right_refs
.iter()
.filter_map(|entry| entry.reference.as_ref())
.any(|right_ref| right_ref.call_expr.get_syntax_id() == left_call_id)
})
}
Comment on lines +54 to 76
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 nested loop in has_shared_multi_return_refs has O(N * M) complexity. While N and M are typically small, pathological cases or stress tests (like the one introduced in this PR) can cause performance degradation.

We can optimize this by using a HashSet when both lists are relatively large, while keeping the fast nested loop path for the common case of small lists.

    pub fn has_shared_multi_return_refs(
        &self,
        left_decl_id: &LuaDeclId,
        right_decl_id: &LuaDeclId,
    ) -> bool {
        let Some(left_refs) = self.decl_multi_return_ref.get(left_decl_id) else {
            return false;
        };
        let Some(right_refs) = self.decl_multi_return_ref.get(right_decl_id) else {
            return false;
        };

        if left_refs.len() > 8 && right_refs.len() > 8 {
            let left_set: HashSet<_> = left_refs
                .iter()
                .filter_map(|entry| entry.reference.as_ref().map(|r| r.call_expr.get_syntax_id()))
                .collect();
            right_refs
                .iter()
                .filter_map(|entry| entry.reference.as_ref())
                .any(|right_ref| left_set.contains(&right_ref.call_expr.get_syntax_id()))
        } else {
            left_refs
                .iter()
                .filter_map(|entry| entry.reference.as_ref())
                .any(|left_ref| {
                    let left_call_id = left_ref.call_expr.get_syntax_id();
                    right_refs
                        .iter()
                        .filter_map(|entry| entry.reference.as_ref())
                        .any(|right_ref| right_ref.call_expr.get_syntax_id() == left_call_id)
                })
        }
    }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The O(N*M) shape is theoretically valid, but I do not think a thresholded HashSet is the right fix here.

This helper is only a preflight gate before the expensive correlated subquery path. Adding an allocation and a magic cutoff makes the common path more complex without evidence that this helper is the bottleneck.

The main perf fix in this PR is skipping unrelated correlation entirely. If profiling later shows this membership check is hot, I would rather fix that at the data-model level by indexing the call ids in FlowTree, so the check becomes a real set intersection instead of a local micro-op.

For now I am keeping the simple nested scan.


/// Chooses the search roots used to resolve correlated multi-return refs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,9 +274,7 @@ fn maybe_type_guard_binary_action(
let Some(target_decl_id) = var_ref_id.get_decl_id_ref() else {
return Ok(None);
};
if !tree.has_decl_multi_return_refs(&discriminant_decl_id)
|| !tree.has_decl_multi_return_refs(&target_decl_id)
{
if !tree.has_shared_multi_return_refs(&discriminant_decl_id, &target_decl_id) {
return Ok(None);
}

Expand Down Expand Up @@ -377,9 +375,7 @@ fn get_var_eq_condition_action(
let Some(target_decl_id) = var_ref_id.get_decl_id_ref() else {
return Ok(ConditionFlowAction::Continue);
};
if !tree.has_decl_multi_return_refs(&discriminant_decl_id)
|| !tree.has_decl_multi_return_refs(&target_decl_id)
{
if !tree.has_shared_multi_return_refs(&discriminant_decl_id, &target_decl_id) {
return Ok(ConditionFlowAction::Continue);
}
let antecedent_flow_id = get_single_antecedent(flow_node)?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,7 @@ pub(in crate::semantic::infer::narrow) fn prepare_var_from_return_overload_condi
let Some(target_decl_id) = var_ref_id.get_decl_id_ref() else {
return Ok(ConditionFlowAction::Continue);
};
if !tree.has_decl_multi_return_refs(&discriminant_decl_id)
|| !tree.has_decl_multi_return_refs(&target_decl_id)
{
if !tree.has_shared_multi_return_refs(&discriminant_decl_id, &target_decl_id) {
return Ok(ConditionFlowAction::Continue);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -554,8 +554,7 @@ pub(super) fn get_type_at_condition_flow(
};

if let Some(target_decl_id) = var_ref_id.get_decl_id_ref()
&& tree.has_decl_multi_return_refs(&decl_id)
&& tree.has_decl_multi_return_refs(&target_decl_id)
&& tree.has_shared_multi_return_refs(&decl_id, &target_decl_id)
{
let antecedent_flow_id = get_single_antecedent(flow_node)?;
let fallback_expr = tree
Expand Down
Loading