diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index b1b4302d5..ad9cfa7b5 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -116,6 +116,11 @@ impl LoweringContext { classes_index: HashMap::new(), imported_functions_index: HashMap::new(), builtin_module_aliases_index: HashMap::new(), + native_instances_index: HashMap::new(), + module_native_instances_index: HashMap::new(), + func_return_native_instances_index: HashMap::new(), + native_modules_index: HashMap::new(), + class_statics_index: HashMap::new(), weakref_locals: HashSet::new(), finreg_locals: HashSet::new(), weakmap_locals: HashSet::new(), @@ -156,6 +161,7 @@ impl LoweringContext { optional_require_try_depth: 0, fn_ctor_env: super::fn_ctor_env::FnCtorEnv::default(), expr_lower_depth: 0, + prelowered_member_receiver: None, } } @@ -620,23 +626,26 @@ impl LoweringContext { static_fields: Vec, static_methods: Vec, ) { + // Forward-scan (first-match-wins): index keeps the FIRST entry per name. + let idx = self.class_statics.len(); + self.class_statics_index + .entry(class_name.clone()) + .or_insert(idx); self.class_statics .push((class_name, static_fields, static_methods)); } pub(crate) fn has_static_field(&self, class_name: &str, field_name: &str) -> bool { - self.class_statics - .iter() - .find(|(cn, _, _)| cn == class_name) - .map(|(_, fields, _)| fields.contains(&field_name.to_string())) + self.class_statics_index + .get(class_name) + .map(|&idx| self.class_statics[idx].1.iter().any(|f| f == field_name)) .unwrap_or(false) } pub(crate) fn has_static_method(&self, class_name: &str, method_name: &str) -> bool { - self.class_statics - .iter() - .find(|(cn, _, _)| cn == class_name) - .map(|(_, _, methods)| methods.contains(&method_name.to_string())) + self.class_statics_index + .get(class_name) + .map(|&idx| self.class_statics[idx].2.iter().any(|m| m == method_name)) .unwrap_or(false) } @@ -1040,15 +1049,21 @@ impl LoweringContext { module_name: String, method_name: Option, ) { + // Forward-scan (first-match-wins) semantics: only record the FIRST + // index for a name so lookups match the old `.iter().find()`. + let idx = self.native_modules.len(); + self.native_modules_index + .entry(local_name.clone()) + .or_insert(idx); self.native_modules .push((local_name, module_name, method_name)); } pub(crate) fn lookup_native_module(&self, name: &str) -> Option<(&str, Option<&str>)> { - self.native_modules - .iter() - .find(|(n, _, _)| n == name) - .map(|(_, m, method)| (m.as_str(), method.as_ref().map(|s| s.as_str()))) + self.native_modules_index.get(name).map(|&idx| { + let (_, m, method) = &self.native_modules[idx]; + (m.as_str(), method.as_ref().map(|s| s.as_str())) + }) } pub(crate) fn register_builtin_module_alias( @@ -1103,10 +1118,39 @@ impl LoweringContext { if is_compile_package_override(&module_name) { return; } + // Push the new index onto this name's shadow stack (innermost last). + let idx = self.native_instances.len(); + self.native_instances_index + .entry(local_name.clone()) + .or_default() + .push(idx); self.native_instances .push((local_name, module_name, class_name)); } + /// Truncate `native_instances` back to `mark`, keeping the + /// `native_instances_index` shadow stacks in sync: every recorded index + /// `>= mark` is popped (these belong to bindings whose scope is exiting), + /// re-exposing any earlier (outer-scope) binding of the same name. Empty + /// stacks are removed to keep the map small. Use this everywhere + /// `native_instances.truncate(..)` was previously called directly. + pub(crate) fn truncate_native_instances(&mut self, mark: usize) { + if self.native_instances.len() <= mark { + return; + } + self.native_instances.truncate(mark); + // Drop indices >= mark from each name's shadow stack, re-exposing any + // earlier (outer-scope) binding. The map is keyed by distinct + // native-instance names (bounded, not proportional to class count), so + // this stays cheap. + self.native_instances_index.retain(|_, stack| { + while stack.last().is_some_and(|&i| i >= mark) { + stack.pop(); + } + !stack.is_empty() + }); + } + /// #1483: resolve a parameter's declared type name to a perry/ui widget /// class that uses handle-based instance dispatch (Canvas, State, ...). /// Returns the canonical widget name (e.g. "Canvas") when `type_name` @@ -1147,10 +1191,17 @@ impl LoweringContext { // inner `res` always resolved to the outer `("http", // "ServerResponse")` tag and `res.on('data')` misrouted // through ServerResponse dispatch instead of IncomingMessage.) - self.native_instances - .iter() - .rev() - .find(|(n, _, _)| n == name) + // + // Indexed (#5271): `native_instances_index[name]` is the shadow stack + // of indices for this name, innermost (last) on top — so the top index + // is exactly the entry the old `.rev().find()` would have selected. + // The `exposes_plain_object_fields` filter is then applied to THAT + // entry only (matching `.find().filter()`, which never falls through to + // an earlier match when the top one is filtered out). + self.native_instances_index + .get(name) + .and_then(|stack| stack.last()) + .map(|&idx| &self.native_instances[idx]) // `node:repl` constructors allocate real heap objects/errors with // bound methods; routing them through handle-dispatch native // getters turns ordinary fields like `Recoverable.err` into @@ -1159,11 +1210,11 @@ impl LoweringContext { .map(|(_, module, class)| (module.as_str(), class.as_str())) .or_else(|| { // Check module-level instances (survive scope exits). - // Same last-match-wins rule for consistency. - self.module_native_instances - .iter() - .rev() - .find(|(n, _, _)| n == name) + // Same last-match-wins rule for consistency — the index stores + // the LAST pushed entry per name. + self.module_native_instances_index + .get(name) + .map(|&idx| &self.module_native_instances[idx]) .filter(|(_, module, class)| !exposes_plain_object_fields(module, class)) .map(|(_, module, class)| (module.as_str(), class.as_str())) }) @@ -1173,10 +1224,35 @@ impl LoweringContext { &self, func_name: &str, ) -> Option<(&str, &str)> { - self.func_return_native_instances - .iter() - .find(|(n, _, _)| n == func_name) - .map(|(_, module, class)| (module.as_str(), class.as_str())) + // Forward-scan (first-match-wins): index keeps the FIRST pushed entry. + self.func_return_native_instances_index + .get(func_name) + .map(|&idx| { + let (_, module, class) = &self.func_return_native_instances[idx]; + (module.as_str(), class.as_str()) + }) + } + + /// Push a function-return native instance (push-only, never truncated) and + /// update its perf index. `lookup_func_return_native_instance` scanned + /// FORWARD (first-match-wins), so the index keeps the FIRST pushed entry. + pub(crate) fn push_func_return_native_instance(&mut self, entry: (String, String, String)) { + let idx = self.func_return_native_instances.len(); + self.func_return_native_instances_index + .entry(entry.0.clone()) + .or_insert(idx); + self.func_return_native_instances.push(entry); + } + + /// Push a module-level native instance (module-scoped, never truncated) + /// and update its perf index. `lookup_native_instance`'s fallback arm + /// scans these in reverse (last-match-wins), so the index stores the LAST + /// pushed entry per name (overwrite). + pub(crate) fn push_module_native_instance(&mut self, entry: (String, String, String)) { + let idx = self.module_native_instances.len(); + self.module_native_instances_index + .insert(entry.0.clone(), idx); + self.module_native_instances.push(entry); } } @@ -1259,7 +1335,7 @@ impl LoweringContext { } self.locals.extend(kept); } - self.native_instances.truncate(mark.1); + self.truncate_native_instances(mark.1); // Remove index entries for functions being truncated, then restore any // earlier entries that were shadowed by the removed ones. for i in mark.2..self.functions.len() { diff --git a/crates/perry-hir/src/lower/expr_assign.rs b/crates/perry-hir/src/lower/expr_assign.rs index 9aa968337..bd3ae1f90 100644 --- a/crates/perry-hir/src/lower/expr_assign.rs +++ b/crates/perry-hir/src/lower/expr_assign.rs @@ -192,7 +192,7 @@ pub(super) fn lower_assign(ctx: &mut LoweringContext, assign: &ast::AssignExpr) _ => Some("Instance"), }; if let Some(class_name) = class_name { - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( var_name.clone(), module_name.to_string(), class_name.to_string(), @@ -217,7 +217,7 @@ pub(super) fn lower_assign(ctx: &mut LoweringContext, assign: &ast::AssignExpr) module_name.clone(), class_name_str.to_string(), ); - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( var_name.clone(), module_name, class_name_str.to_string(), @@ -230,7 +230,7 @@ pub(super) fn lower_assign(ctx: &mut LoweringContext, assign: &ast::AssignExpr) if let ast::Expr::Ident(rhs_ident) = inner_rhs { let rhs_name = rhs_ident.sym.as_ref(); if let Some((module, class)) = ctx.lookup_native_instance(rhs_name) { - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( var_name, module.to_string(), class.to_string(), diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index 8a4f73ef3..856986aa4 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -143,12 +143,21 @@ pub(super) fn lower_call(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Res // are likewise out of scope once we unwind past their owning // call). `truncate` is a no-op when nothing was added. if ctx.native_instances.len() > ni_mark { - ctx.native_instances.truncate(ni_mark); + ctx.truncate_native_instances(ni_mark); } result } fn lower_call_inner(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Result { + // Safety net for the receiver-lowering memo (see + // `LoweringContext::prelowered_member_receiver`): the memo is set by + // `try_static_method_and_instance` only to be consumed by the immediately + // following fall-through tail's `lower_member_inner`. It is single-shot and + // span-keyed, but in case a code path sets it without the tail consuming it, + // drop any stale entry here so it can never leak across calls. This runs + // before the callee tail (which lowers a Member, not a Call), so it never + // clobbers an in-flight memo for the call currently being lowered. + ctx.prelowered_member_receiver = None; // Check if any argument has spread let has_spread = call.args.iter().any(|arg| arg.spread.is_some()); diff --git a/crates/perry-hir/src/lower/expr_call/static_and_instance.rs b/crates/perry-hir/src/lower/expr_call/static_and_instance.rs index 59541ea50..62e983880 100644 --- a/crates/perry-hir/src/lower/expr_call/static_and_instance.rs +++ b/crates/perry-hir/src/lower/expr_call/static_and_instance.rs @@ -4,6 +4,7 @@ use anyhow::{anyhow, Result}; use perry_types::{LocalId, Type}; +use swc_common::Spanned; use swc_ecma_ast as ast; use super::stream::is_stream_api_method; @@ -345,7 +346,27 @@ pub(super) fn try_static_method_and_instance( if !may_lower_to_native_method_call(ctx, &member.obj) { return Ok(Err(args)); } - // Lower the object expression first + // Lower the object expression once. + // + // Perf (O(n²)/exponential → O(n) on long native-fluent chains): this + // is a FULL recursive lowering of the entire receiver prefix, done + // speculatively to inspect whether the inner call lowered to a + // `NativeMethodCall` of a recognized fluent module. On a chain like + // `K.name(..).description(..).option(..)…` (commander / minified CLI + // builders) where `may_lower_to_native_method_call` over-approximates + // to `true` but the inner call actually lowers to a *generic* `Call`, + // every arm below misses, `object_expr` is discarded, and we return + // `Err(args)` — whereupon the `lower_call_inner` fall-through tail + // re-lowers the same member callee (and thus this whole prefix) + // again. Repeated per chain level that is exponential blowup. + // + // To make the tail reuse this lowering instead of redoing it, stash + // it keyed by the receiver's source span. `lower_member_inner` (the + // tail's receiver-lowering site) consumes it for the matching span. + // Reuse is sound: lowering a receiver is idempotent in the value it + // produces, and the fluent-success arms below already reuse this very + // `object_expr`. + let obj_span = member.obj.as_ref().span(); let object_expr = lower_expr(ctx, &member.obj)?; // Check if it's a NativeMethodCall for a fluent-API native module if let Expr::NativeMethodCall { @@ -488,6 +509,12 @@ pub(super) fn try_static_method_and_instance( })); } } + // No fluent arm matched: we are about to return `Err(args)` and the + // `lower_call_inner` fall-through tail will re-lower this same member + // callee. Hand it the receiver we just lowered so it doesn't repeat + // the (potentially whole-prefix) work. Keyed by span so the tail only + // reuses it for the exact receiver subtree. + ctx.prelowered_member_receiver = Some(((obj_span.lo.0, obj_span.hi.0), object_expr)); } } diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index 592d7908a..cfe9813fb 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -219,13 +219,6 @@ pub(super) fn lower_arrow(ctx: &mut LoweringContext, arrow: &ast::ArrowExpr) -> .unwrap_or_default(); ctx.enter_type_param_scope(&arrow_type_params); - // Track which locals exist before entering the closure scope - let outer_locals: Vec<(String, LocalId)> = ctx - .locals - .iter() - .map(|(name, id, _)| (name.clone(), *id)) - .collect(); - // Lower parameters and collect destructuring info let mut params = Vec::new(); let mut destructuring_params: Vec<(LocalId, ast::Pat)> = Vec::new(); @@ -413,7 +406,11 @@ pub(super) fn lower_arrow(ctx: &mut LoweringContext, arrow: &ast::ArrowExpr) -> // arrows don't leak outer T/U bindings into sibling code. ctx.exit_type_param_scope(); - let (captures, mutable_captures) = compute_closure_captures(ctx, &body, &outer_locals, ¶ms); + // The closure's own scope has been popped, so `ctx.locals.id_set()` is now + // exactly the enclosing scope's live locals — the membership view capture + // analysis needs. (Previously rebuilt per closure from a cloned snapshot.) + let (captures, mutable_captures) = + compute_closure_captures(ctx, &body, ctx.locals.id_set(), ¶ms); // Check if this arrow function uses `this` (needs to capture it from enclosing scope) let captures_this = closure_uses_this(&body); @@ -501,11 +498,15 @@ fn lower_named_fn_expr( // what the wrapper itself captures and threads through to the inner // function. let wrapper_scope = ctx.enter_scope(); - let outer_locals: Vec<(String, LocalId)> = ctx - .locals - .iter() - .map(|(name, id, _)| (name.clone(), *id)) - .collect(); + // Snapshot the enclosing locals *before* the self-binding is added, so the + // wrapper captures them (not the self-binding). Unlike the arrow/fn-expr + // paths, capture analysis runs here while the wrapper scope is still open + // (the self-binding lives in it), so we can't use `ctx.locals.id_set()` — + // it would wrongly include `self_id`. This is a rare path (named function + // expressions that recursively self-reference), so an explicit snapshot is + // fine. + let outer_local_ids: std::collections::HashSet = + ctx.locals.iter().map(|(_, id, _)| *id).collect(); let self_id = ctx.define_local(own_name.clone(), Type::Any); // Lower the function itself as an anonymous closure. With `self_id` @@ -533,7 +534,7 @@ fn lower_named_fn_expr( }, Stmt::Return(Some(Expr::LocalGet(self_id))), ]; - let (captures, mutable_captures) = compute_closure_captures(ctx, &body, &outer_locals, &[]); + let (captures, mutable_captures) = compute_closure_captures(ctx, &body, &outer_local_ids, &[]); ctx.exit_scope(wrapper_scope); Ok(Expr::Call { @@ -577,13 +578,6 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul let saved_field_init = ctx.in_class_field_init; ctx.in_class_field_init = false; - // Track which locals exist before entering the closure scope - let outer_locals: Vec<(String, LocalId)> = ctx - .locals - .iter() - .map(|(name, id, _)| (name.clone(), *id)) - .collect(); - // Lower parameters and collect destructuring info. // // Refs #915 (gap 1 from #899 — Effect's `dual(arity, body)`): TypeScript's @@ -1038,7 +1032,9 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul ctx.exit_scope(scope_mark); ctx.in_class_field_init = saved_field_init; - let (captures, mutable_captures) = compute_closure_captures(ctx, &body, &outer_locals, ¶ms); + // Scope popped: `ctx.locals.id_set()` is now the enclosing scope's locals. + let (captures, mutable_captures) = + compute_closure_captures(ctx, &body, ctx.locals.id_set(), ¶ms); // #2076: a named function expression's own ident is its `fn.name` // per spec, regardless of the binding identifier it's later assigned @@ -1081,7 +1077,7 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul fn compute_closure_captures( ctx: &LoweringContext, body: &[Stmt], - outer_locals: &[(String, LocalId)], + outer_local_ids: &std::collections::HashSet, params: &[Param], ) -> (Vec, Vec) { // Detect captured variables: locals referenced in the body that @@ -1092,10 +1088,13 @@ fn compute_closure_captures( collect_local_refs_stmt(stmt, &mut all_refs, &mut visited_closures); } - // Filter to only include outer locals (not parameters or locals - // defined within the closure). - let outer_local_ids: std::collections::HashSet = - outer_locals.iter().map(|(_, id)| *id).collect(); + // `outer_local_ids` is the membership view of the enclosing scope's + // locals, supplied by the caller (the live `ctx.locals.id_set()` once the + // closure's own scope has been popped). Previously this was rebuilt into a + // fresh `HashSet` from an `&[(String, LocalId)]` snapshot on *every* + // closure — O(scope) per closure, i.e. O(n²) over n sibling closures in a + // large scope. We only ever need membership tests here, so the caller's + // incrementally-maintained set is reused directly. let param_ids: std::collections::HashSet = params.iter().map(|p| p.id).collect(); // dayjs (issue: format() returned `292278994-08`): local IDs are diff --git a/crates/perry-hir/src/lower/expr_member.rs b/crates/perry-hir/src/lower/expr_member.rs index b932d096e..50bc1b02d 100644 --- a/crates/perry-hir/src/lower/expr_member.rs +++ b/crates/perry-hir/src/lower/expr_member.rs @@ -11,6 +11,7 @@ use anyhow::Result; use perry_types::Type; +use swc_common::Spanned; use swc_ecma_ast as ast; use crate::ir::Expr; @@ -1781,7 +1782,17 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re } } - let mut object_expr = lower_expr(ctx, &member.obj)?; + // Perf: reuse a receiver already lowered by `try_static_method_and_instance` + // (the chained-native-method dispatch helper) for THIS exact member callee, + // instead of re-lowering the whole prefix. See + // `LoweringContext::prelowered_member_receiver`. Match strictly by span and + // take it (single-shot) so a stale memo can never leak onto a different + // receiver. Any other consumer along the way invalidates it. + let obj_span = member.obj.as_ref().span(); + let mut object_expr = match ctx.prelowered_member_receiver.take() { + Some((key, lowered)) if key == (obj_span.lo.0, obj_span.hi.0) => lowered, + _ => lower_expr(ctx, &member.obj)?, + }; if let ast::MemberProp::Ident(prop_ident) = &member.prop { if let Some(value) = ws_ready_state_value(prop_ident.sym.as_ref()) { if is_ws_ready_state_receiver(ctx, member.obj.as_ref(), &object_expr) { diff --git a/crates/perry-hir/src/lower/fn_ctor_env.rs b/crates/perry-hir/src/lower/fn_ctor_env.rs index 48e7bbb0d..40157159f 100644 --- a/crates/perry-hir/src/lower/fn_ctor_env.rs +++ b/crates/perry-hir/src/lower/fn_ctor_env.rs @@ -214,10 +214,10 @@ pub(crate) fn build_fn_ctor_env(module: &ast::Module) -> FnCtorEnv { let mut decls: HashMap)> = HashMap::new(); let mut writes: HashMap = HashMap::new(); - let empty_shadow = Shadow::new(); + let mut empty_shadow = Shadow::new(); for item in &module.body { if let ast::ModuleItem::Stmt(stmt) = item { - scan_stmt(stmt, &mut decls, &mut writes, &empty_shadow); + scan_stmt(stmt, &mut decls, &mut writes, &mut empty_shadow); } } @@ -783,7 +783,7 @@ fn collect_fn_scope_names(stmts: &[ast::Stmt], out: &mut Shadow) { /// Treat every binding identifier in a pattern as a write (catch clauses, /// destructuring) — it shadows or mutates the name. -fn record_pat_bindings(pat: &ast::Pat, writes: &mut HashMap, shadow: &Shadow) { +fn record_pat_bindings(pat: &ast::Pat, writes: &mut HashMap, shadow: &mut Shadow) { match pat { ast::Pat::Ident(b) => record_write(&b.id.sym, writes, shadow), ast::Pat::Array(arr) => { @@ -820,7 +820,7 @@ fn scan_stmt( stmt: &ast::Stmt, decls: &mut HashMap)>, writes: &mut HashMap, - shadow: &Shadow, + shadow: &mut Shadow, ) { match stmt { ast::Stmt::Decl(ast::Decl::Var(var)) => { @@ -939,7 +939,11 @@ fn scan_stmt( } } -fn scan_for_head_writes(head: &ast::ForHead, writes: &mut HashMap, shadow: &Shadow) { +fn scan_for_head_writes( + head: &ast::ForHead, + writes: &mut HashMap, + shadow: &mut Shadow, +) { match head { ast::ForHead::VarDecl(v) => { for d in &v.decls { @@ -961,7 +965,7 @@ fn scan_for_head_writes(head: &ast::ForHead, writes: &mut HashMap fn scan_assign_target_writes( target: &ast::AssignTarget, writes: &mut HashMap, - shadow: &Shadow, + shadow: &mut Shadow, ) { match target { ast::AssignTarget::Simple(simple) => match simple { @@ -999,27 +1003,52 @@ fn scan_assign_target_writes( /// Walk a nested function for writes to NON-shadowed (module-level) names. /// The function's params and its own hoisted declarations extend the shadow. +/// +/// The shadow is threaded as a single shared `&mut Shadow` that we push the +/// function's own names onto and pop afterwards, instead of `clone()`ing the +/// whole enclosing shadow per nested function. The old clone made scanning a +/// scope of N sibling nested functions O(N²) (each clone copies the ~N +/// enclosing names) — pathological for modules/wrapper-IIFEs that declare many +/// sibling closures (the same class of perf bug as the capture-set rebuild). +/// To restore the shadow exactly, we only remove the names this frame newly +/// inserted (a name already shadowed by an outer scope must stay shadowed). fn scan_fn_body_writes( params: &[&ast::Pat], stmts: &[ast::Stmt], writes: &mut HashMap, - outer_shadow: &Shadow, + shadow: &mut Shadow, ) { - let mut shadow = outer_shadow.clone(); + // Gather the names this function frame introduces (params + hoisted + // declarations) without disturbing the shared shadow. + let mut frame_names = Shadow::new(); for p in params { - collect_pat_names(p, &mut shadow); + collect_pat_names(p, &mut frame_names); + } + collect_fn_scope_names(stmts, &mut frame_names); + + // Insert only the names not already shadowed, remembering them so we can + // pop exactly this frame's additions and leave outer shadows intact. + let mut added: Vec = Vec::new(); + for name in frame_names { + if shadow.insert(name.clone()) { + added.push(name); + } } - collect_fn_scope_names(stmts, &mut shadow); + let mut nested_decls: HashMap)> = HashMap::new(); for s in stmts { - scan_stmt(s, &mut nested_decls, writes, &shadow); + scan_stmt(s, &mut nested_decls, writes, shadow); + } + + for name in added { + shadow.remove(&name); } } fn scan_function_writes( function: &ast::Function, writes: &mut HashMap, - shadow: &Shadow, + shadow: &mut Shadow, ) { let params: Vec<&ast::Pat> = function.params.iter().map(|p| &p.pat).collect(); let stmts: &[ast::Stmt] = function @@ -1030,7 +1059,7 @@ fn scan_function_writes( scan_fn_body_writes(¶ms, stmts, writes, shadow); } -fn scan_class_writes(class: &ast::Class, writes: &mut HashMap, shadow: &Shadow) { +fn scan_class_writes(class: &ast::Class, writes: &mut HashMap, shadow: &mut Shadow) { if let Some(sup) = &class.super_class { scan_expr_writes(sup, writes, shadow); } @@ -1069,7 +1098,7 @@ fn scan_class_writes(class: &ast::Class, writes: &mut HashMap, sh } } -fn scan_expr_writes(expr: &ast::Expr, writes: &mut HashMap, shadow: &Shadow) { +fn scan_expr_writes(expr: &ast::Expr, writes: &mut HashMap, shadow: &mut Shadow) { match expr { ast::Expr::Assign(a) => { scan_assign_target_writes(&a.left, writes, shadow); @@ -1174,11 +1203,23 @@ fn scan_expr_writes(expr: &ast::Expr, writes: &mut HashMap, shado scan_fn_body_writes(¶ms, &b.stmts, writes, shadow); } ast::BlockStmtOrExpr::Expr(e) => { - let mut inner = shadow.clone(); + // Arrow expression body: push the params, scan, then pop — + // mirrors `scan_fn_body_writes` so we don't clone the whole + // enclosing shadow per arrow. + let mut frame_names = Shadow::new(); for p in ¶ms { - collect_pat_names(p, &mut inner); + collect_pat_names(p, &mut frame_names); + } + let mut added: Vec = Vec::new(); + for name in frame_names { + if shadow.insert(name.clone()) { + added.push(name); + } + } + scan_expr_writes(e, writes, shadow); + for name in added { + shadow.remove(&name); } - scan_expr_writes(e, writes, &inner); } } } diff --git a/crates/perry-hir/src/lower/locals.rs b/crates/perry-hir/src/lower/locals.rs index 1912424f4..7e3a150d6 100644 --- a/crates/perry-hir/src/lower/locals.rs +++ b/crates/perry-hir/src/lower/locals.rs @@ -26,7 +26,7 @@ use std::ops::{Deref, DerefMut}; use perry_types::{LocalId, Type}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; /// Ordered stack of scope-local bindings with a name→positions index. #[derive(Debug, Clone, Default)] @@ -36,6 +36,17 @@ pub(crate) struct Locals { /// name -> ascending positions into `entries`. The last entry of each /// list is the innermost (most-recent) binding for that name. index: HashMap>, + /// Set of the `LocalId`s currently present in `entries`. Maintained in + /// lockstep with `entries` by every mutating method, so closure-capture + /// analysis can do O(1) "is this id an enclosing local?" membership tests + /// against the *current* scope without rebuilding a `HashSet` from the + /// `entries` slice on every closure. Each binding gets a fresh monotonic + /// `LocalId` (`fresh_local`), so ids are unique within `entries` and a + /// plain set (no refcount) stays in sync. See `compute_closure_captures` + /// and `nested_fn_decl` — both formerly rebuilt this set per closure, + /// making capture analysis O(scope) per closure = O(n²) over a scope of + /// n sibling closures. + id_set: HashSet, } impl Locals { @@ -47,9 +58,17 @@ impl Locals { pub(crate) fn push(&mut self, entry: (String, LocalId, Type)) { let pos = self.entries.len(); self.index.entry(entry.0.clone()).or_default().push(pos); + self.id_set.insert(entry.1); self.entries.push(entry); } + /// The set of `LocalId`s currently in scope. O(1). Used by closure-capture + /// analysis as the "enclosing locals" membership view, replacing a + /// per-closure rebuild from the `entries` slice (#5267 follow-up). + pub(crate) fn id_set(&self) -> &HashSet { + &self.id_set + } + /// Position of the innermost binding named `name`, if any. O(1). /// Equivalent to the old `iter().rposition(|(n, ..)| n == name)`. pub(crate) fn lookup_index(&self, name: &str) -> Option { @@ -132,6 +151,9 @@ impl Locals { self.index.remove(name); } } + for (_, id, _) in &drained { + self.id_set.remove(id); + } drained } @@ -151,11 +173,13 @@ impl Locals { removed } - /// Rebuild the whole name→positions index from `entries`. + /// Rebuild the whole name→positions index and the id set from `entries`. fn reindex(&mut self) { self.index.clear(); - for (pos, (name, _, _)) in self.entries.iter().enumerate() { + self.id_set.clear(); + for (pos, (name, id, _)) in self.entries.iter().enumerate() { self.index.entry(name.clone()).or_default().push(pos); + self.id_set.insert(*id); } } } diff --git a/crates/perry-hir/src/lower/lower_expr.rs b/crates/perry-hir/src/lower/lower_expr.rs index a4a62a669..987098fe3 100644 --- a/crates/perry-hir/src/lower/lower_expr.rs +++ b/crates/perry-hir/src/lower/lower_expr.rs @@ -495,7 +495,68 @@ fn expr_uses_stack_heavy_chain_lowering(expr: &ast::Expr) -> bool { matches!(expr, ast::Expr::Bin(_) | ast::Expr::Member(_)) } +/// Re-lowering diagnostics, fully gated behind the `PERRY_TRACE_RELOWER` env +/// var (zero overhead unless set). Counts every `lower_expr` invocation keyed +/// by source span, so a span lowered far more than once flags redundant +/// re-lowering (the classic source of super-linear HIR-lowering blowup on +/// minified bundles). On every N-million calls — and so still on a kill — it +/// dumps the total/distinct counts and the top re-lowered spans to stderr. +/// Kept (env-gated) as a standing diagnostic for future lowering perf work. +pub(crate) mod relower_trace { + use std::cell::RefCell; + use std::collections::HashMap; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + + static ENABLED: AtomicBool = AtomicBool::new(false); + static INIT: AtomicBool = AtomicBool::new(false); + static TOTAL: AtomicU64 = AtomicU64::new(0); + + thread_local! { + static SPANS: RefCell> = RefCell::new(HashMap::new()); + } + + pub fn enabled() -> bool { + if !INIT.load(Ordering::Relaxed) { + let on = std::env::var("PERRY_TRACE_RELOWER").is_ok(); + ENABLED.store(on, Ordering::Relaxed); + INIT.store(true, Ordering::Relaxed); + } + ENABLED.load(Ordering::Relaxed) + } + + pub fn record(lo: u32, hi: u32) { + let n = TOTAL.fetch_add(1, Ordering::Relaxed) + 1; + SPANS.with(|m| { + *m.borrow_mut().entry((lo, hi)).or_insert(0) += 1; + }); + if n % 5_000_000 == 0 { + dump(&format!("periodic@{n}")); + } + } + + fn dump(tag: &str) { + SPANS.with(|m| { + let m = m.borrow(); + let total = TOTAL.load(Ordering::Relaxed); + let distinct = m.len(); + let mut v: Vec<_> = m.iter().map(|(k, c)| (*c, *k)).collect(); + v.sort_unstable_by(|a, b| b.0.cmp(&a.0)); + eprintln!( + "RELOWER[{tag}] total={total} distinct={distinct} ratio={:.2}", + total as f64 / distinct.max(1) as f64 + ); + for (c, (lo, hi)) in v.into_iter().take(20) { + eprintln!("RELOWER span {lo}..{hi} count={c}"); + } + }); + } +} + pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result { + if relower_trace::enabled() { + let sp = expr.span(); + relower_trace::record(sp.lo.0, sp.hi.0); + } // #5259: guard the recursive descent. Without this, a pathologically // nested expression (`1+1+…`, `o.a.a.…`, `a||a||…`) overflows the native // stack and SIGABRTs with no diagnostic. The depth counter turns that into diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index c68fe34d6..9adbada6a 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -368,6 +368,32 @@ pub struct LoweringContext { pub(crate) imported_functions_index: HashMap, /// Shadow index: local alias name -> index in `builtin_module_aliases` Vec pub(crate) builtin_module_aliases_index: HashMap, + /// Perf index for `native_instances` (which is scope-stack-like: pushed on + /// scope entry, truncated on scope exit). Maps name -> STACK of indices into + /// the `native_instances` Vec, innermost (last) on top. `lookup_native_instance` + /// reads the top index for O(1) last-match-wins resolution (mirrors the old + /// reverse scan's shadowing); on `truncate(mark)` every index >= mark is + /// popped off each name's stack (and empty stacks removed), so an inner + /// binding stops shadowing the moment its scope pops. See + /// `register_native_instance` / `truncate_native_instances`. + pub(crate) native_instances_index: HashMap>, + /// Perf index for `module_native_instances` (module-level, push-only, never + /// truncated). Scanned in reverse (last-match-wins) by + /// `lookup_native_instance`'s fallback arm, so the index stores the LAST + /// pushed index per name (overwritten on every push). O(1) lookup. + pub(crate) module_native_instances_index: HashMap, + /// Perf index for `func_return_native_instances` (push-only, never + /// truncated). The old lookup scanned FORWARD (first-match-wins), so the + /// index keeps the FIRST pushed index per name (`entry().or_insert`). + pub(crate) func_return_native_instances_index: HashMap, + /// Perf index for `native_modules` (push-only, never truncated). The old + /// `lookup_native_module` scanned FORWARD (first-match-wins), so the index + /// keeps the FIRST pushed index per name (`entry().or_insert`). + pub(crate) native_modules_index: HashMap, + /// Perf index for `class_statics` (push-only, never truncated). The old + /// `has_static_method`/`has_static_field` scanned FORWARD (first-match-wins), + /// so the index keeps the FIRST pushed index per class name. + pub(crate) class_statics_index: HashMap, /// Local names bound to a `path` sub-namespace (`const w = path.win32`). /// Maps the local name -> (root identifier name, sub "win32"|"posix"). /// Resolution of the root identifier to the `path` module is deferred to @@ -618,4 +644,19 @@ pub struct LoweringContext { /// letting pathologically-nested expressions overflow the native stack and /// SIGABRT. pub(crate) expr_lower_depth: u32, + /// Perf: a single-slot memo of an already-lowered member receiver, keyed by + /// its source span `(lo, hi)`. Set by the chained-native-method dispatch + /// helper (`try_static_method_and_instance`) just before it returns + /// `Err(args)` after lowering `member.obj` to inspect it; consumed once by + /// `lower_member_inner` when the `lower_call_inner` fall-through tail + /// re-lowers the same member callee. Without it, a long native-fluent + /// method chain (`K.name(..).description(..).option(..)…` — commander/minified + /// CLI builders) re-lowers the entire receiver prefix at every chain level + /// (the helper lowers it, finds the inner result is not a `NativeMethodCall`, + /// discards it, and the tail lowers it again — compounding to exponential + /// blowup). The memo lets the tail reuse the helper's lowering, so each + /// receiver subtree is lowered exactly once. Lowering a receiver is + /// idempotent w.r.t. the value produced (the fluent-success path already + /// reuses the same lowered receiver), so reusing it is semantics-preserving. + pub(crate) prelowered_member_receiver: Option<((u32, u32), Expr)>, } diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 186e829cc..d9db3a1c7 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -486,7 +486,7 @@ pub(crate) fn lower_module_decl( if let Some((module, class)) = native_instance_from_return_type(&func.return_type) { - ctx.func_return_native_instances.push(( + ctx.push_func_return_native_instance(( func_name.clone(), module.to_string(), class.to_string(), @@ -784,7 +784,7 @@ pub(crate) fn lower_module_decl( // Without this, pool = mysql.createPool() at module top level loses // its native tracking when function scopes are entered/exited, // causing pool.query() inside functions to miss the Pool dispatch. - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( name.clone(), class_module, class_name.to_string(), @@ -868,7 +868,7 @@ pub(crate) fn lower_module_decl( module.clone(), class_name.to_string(), ); - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( name.clone(), module, class_name.to_string(), @@ -969,7 +969,7 @@ pub(crate) fn lower_module_decl( module_name.clone(), class_name_str.to_string(), ); - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( name.clone(), module_name, class_name_str.to_string(), @@ -1011,7 +1011,7 @@ pub(crate) fn lower_module_decl( module_name.clone(), class_name_str.to_string(), ); - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( name.clone(), module_name, class_name_str.to_string(), @@ -1141,7 +1141,7 @@ pub(crate) fn lower_module_decl( } }; if let Some((module, class)) = module_info { - ctx.func_return_native_instances.push(( + ctx.push_func_return_native_instance(( name.clone(), module.to_string(), class.to_string(), @@ -1699,7 +1699,7 @@ pub(crate) fn lower_module_decl( if let Some((mod_name, class)) = native_instance_from_return_type(&func.return_type) { - ctx.func_return_native_instances.push(( + ctx.push_func_return_native_instance(( func_name.clone(), mod_name.to_string(), class.to_string(), @@ -2234,7 +2234,7 @@ pub(crate) fn lower_namespace_as_class( if let Some((module, class)) = native_instance_from_return_type(&func.return_type) { - ctx.func_return_native_instances.push(( + ctx.push_func_return_native_instance(( func.name.clone(), module.to_string(), class.to_string(), diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index 49ff32bf6..84f3ee451 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -327,7 +327,7 @@ pub(crate) fn lower_stmt( if let Some((module, class)) = native_instance_from_return_type(&func.return_type) { - ctx.func_return_native_instances.push(( + ctx.push_func_return_native_instance(( func.name.clone(), module.to_string(), class.to_string(), @@ -929,7 +929,7 @@ pub(crate) fn lower_stmt( module_owned.clone(), cn.to_string(), ); - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( name.clone(), module_owned, cn.to_string(), @@ -946,7 +946,7 @@ pub(crate) fn lower_stmt( module_owned.clone(), cn.to_string(), ); - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( name.clone(), module_owned, cn.to_string(), @@ -990,7 +990,7 @@ pub(crate) fn lower_stmt( "net".to_string(), "Server".to_string(), ); - ctx.module_native_instances.push(( + ctx.push_module_native_instance(( name.clone(), "net".to_string(), "Server".to_string(), diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 95dd8c0f6..5e9a3f5bc 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -312,6 +312,109 @@ fn test_lower_rejects_deep_logical_chain() { assert_too_deep(format!("var a = 0;\nvar x = {};\n", chain.join("||"))); } +/// #5271: the perf index over `native_instances` must reproduce the old +/// reverse-scan semantics exactly — innermost (last-registered) binding wins, +/// and `truncate_native_instances` re-exposes the outer binding when the inner +/// scope pops. Mirrors the `lookup_native_instance` last-match-wins rule. +#[test] +fn test_native_instance_index_shadowing_and_truncation() { + let mut ctx = make_ctx(); + // Outer binding `e` -> events/EventEmitter. + ctx.register_native_instance( + "e".to_string(), + "events".to_string(), + "EventEmitter".to_string(), + ); + assert_eq!( + ctx.lookup_native_instance("e"), + Some(("events", "EventEmitter")) + ); + + // Enter an inner scope: shadow `e` with a different native type. + let mark = ctx.native_instances.len(); + ctx.register_native_instance( + "e".to_string(), + "stream".to_string(), + "Readable".to_string(), + ); + // Inner (last) binding wins. + assert_eq!( + ctx.lookup_native_instance("e"), + Some(("stream", "Readable")) + ); + + // Pop the inner scope: the outer binding must be restored. + ctx.truncate_native_instances(mark); + assert_eq!( + ctx.lookup_native_instance("e"), + Some(("events", "EventEmitter")) + ); + + // Pop the outer binding too: no entry remains. + ctx.truncate_native_instances(0); + assert!(ctx.lookup_native_instance("e").is_none()); +} + +/// #5271: module-level native instances (never truncated) keep last-match-wins +/// via the overwrite index, matching the old reverse scan of the fallback arm. +#[test] +fn test_module_native_instance_index_last_wins() { + let mut ctx = make_ctx(); + ctx.push_module_native_instance(( + "db".to_string(), + "mongodb".to_string(), + "MongoClient".to_string(), + )); + assert_eq!( + ctx.lookup_native_instance("db"), + Some(("mongodb", "MongoClient")) + ); + // A later registration of the same name shadows the earlier one. + ctx.push_module_native_instance(( + "db".to_string(), + "mysql2/promise".to_string(), + "Pool".to_string(), + )); + assert_eq!( + ctx.lookup_native_instance("db"), + Some(("mysql2/promise", "Pool")) + ); +} + +/// #5271 perf gate (run with `--release --ignored`): time M lookups against a +/// K-sized registry to show indexed lookups are ~flat in K (O(1)) rather than +/// O(K) per call. Prints timings; not asserted (machine-dependent) but the +/// flatness across K is the observable signal. Covers the registries whose +/// linear scans this change indexed. +#[test] +#[ignore] +fn perf_registry_lookup_is_flat_in_k() { + use std::time::Instant; + const M: usize = 20_000; + for k in [0usize, 2_000, 8_000, 16_000] { + let mut ctx = make_ctx(); + for i in 0..k { + ctx.register_class_statics( + format!("K{i}"), + vec![format!("f{i}")], + vec![format!("s{i}")], + ); + ctx.register_native_instance(format!("ni{i}"), "events".into(), "EventEmitter".into()); + ctx.register_native_module(format!("nm{i}"), "fs".into(), None); + } + // The hot case the bug targets: the receiver is NOT in the registry, so + // the old reverse/forward scan walked the whole Vec and returned None. + let t = Instant::now(); + let mut acc = 0u64; + for _ in 0..M { + acc += ctx.has_static_method("Missing", "s") as u64; + acc += ctx.lookup_native_instance("missing").is_some() as u64; + acc += ctx.lookup_native_module("missing").is_some() as u64; + } + eprintln!("K={k:<6} {M} x3 lookups: {:?} (acc={acc})", t.elapsed()); + } +} + /// A chain comfortably under the ceiling still lowers cleanly — the guard /// must not reject ordinary (if large) expressions. #[test] diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index 5993e484c..74d8cd756 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -1848,7 +1848,7 @@ pub fn find_native_return_in_stmts( let var = ident.sym.as_ref(); for i in ni_start..ctx.native_instances.len() { if ctx.native_instances[i].0 == var { - ctx.func_return_native_instances.push(( + ctx.push_func_return_native_instance(( func_name.to_string(), ctx.native_instances[i].1.clone(), ctx.native_instances[i].2.clone(), diff --git a/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs b/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs index fe500bafb..ccaeabbee 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs @@ -38,13 +38,6 @@ pub(super) fn lower_nested_fn_decl( let scope_mark = ctx.enter_scope(); - // Track outer locals for capture detection - let outer_locals: Vec<(String, LocalId)> = ctx - .locals - .iter() - .map(|(name, id, _)| (name.clone(), *id)) - .collect(); - // Lower parameters. Skip the TypeScript `this:` annotation — // it has no runtime existence (see the sibling site above for // the full rationale). @@ -170,8 +163,13 @@ pub(super) fn lower_nested_fn_decl( collect_local_refs_stmt(stmt, &mut all_refs, &mut visited_closures); } - let outer_local_ids: std::collections::HashSet = - outer_locals.iter().map(|(_, id)| *id).collect(); + // The function's own scope has been popped (`exit_scope` above), so the + // live `ctx.locals.id_set()` is exactly the enclosing scope's locals — the + // membership view capture detection needs. Previously this was rebuilt into + // a fresh `HashSet` from a per-closure cloned snapshot of `ctx.locals`, + // which made capture analysis O(scope) per nested function = O(n²) over a + // scope of n sibling functions (the perf bug behind this change). + let outer_local_ids = ctx.locals.id_set(); let param_ids: std::collections::HashSet = params.iter().map(|p| p.id).collect(); // dayjs (issue: format() returned `292278994-08`): local diff --git a/crates/perry-hir/src/lower_decl/fn_decl.rs b/crates/perry-hir/src/lower_decl/fn_decl.rs index 07d7e35a1..bca121742 100644 --- a/crates/perry-hir/src/lower_decl/fn_decl.rs +++ b/crates/perry-hir/src/lower_decl/fn_decl.rs @@ -227,7 +227,7 @@ pub fn lower_fn_decl(ctx: &mut LoweringContext, fn_decl: &ast::FnDecl) -> Result let module_alias = &type_name[..dot_pos]; let class_name = &type_name[dot_pos + 1..]; if let Some((module_name, _)) = ctx.lookup_native_module(module_alias) { - ctx.func_return_native_instances.push(( + ctx.push_func_return_native_instance(( name.clone(), module_name.to_string(), class_name.to_string(), @@ -245,7 +245,7 @@ pub fn lower_fn_decl(ctx: &mut LoweringContext, fn_decl: &ast::FnDecl) -> Result _ => None, }; if let Some((module, class)) = module_info { - ctx.func_return_native_instances.push(( + ctx.push_func_return_native_instance(( name.clone(), module.to_string(), class.to_string(),