Skip to content
Open
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
81 changes: 78 additions & 3 deletions pyrefly/lib/alt/callable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use pyrefly_util::prelude::SliceExt;
use pyrefly_util::prelude::VecExt;
use pyrefly_util::visit::VisitMut;
use ruff_python_ast::Expr;
use ruff_python_ast::ExprSubscript;
use ruff_python_ast::Identifier;
use ruff_python_ast::Keyword;
use ruff_python_ast::name::Name;
Expand Down Expand Up @@ -285,6 +286,11 @@ impl<'a> CallArg<'a> {
}
}
}
let bounded_len = if let TypeOrExpr::Expr(Expr::Subscript(subscript)) = e {
Self::bounded_star_slice_len(subscript, solver, arg_errors)
} else {
None
};
let ty = e.infer(solver, arg_errors);
let iterables = solver.iterate(&ty, *_range, arg_errors, None);
// If we have a union of iterables, use a fixed length only if every iterable is
Expand All @@ -310,13 +316,43 @@ impl<'a> CallArg<'a> {
}
let tys = fixed_tys.into_map(|tys| solver.unions(tys));
CallArgPreEval::Fixed(tys, 0)
} else if let Some(max_len) = bounded_len {
CallArgPreEval::Bounded(solver.get_produced_type(iterables), 0, max_len)
} else {
let ty = solver.get_produced_type(iterables);
CallArgPreEval::Star(ty, false)
}
}
}
}

/// Some simple slices have a statically known upper bound on how many values
/// they can contribute when star-unpacked.
fn bounded_star_slice_len<Ans: LookupAnswer>(
subscript: &ExprSubscript,
solver: &AnswersSolver<Ans>,
arg_errors: &ErrorCollector,
) -> Option<usize> {
let Expr::Slice(slice) = &*subscript.slice else {
return None;
};
if slice.step.is_some() {
return None;
}
let parse_literal = |expr: &Option<Box<Expr>>| -> Option<i64> {
let expr = expr.as_ref()?;
match solver.expr_infer(expr, arg_errors) {
Type::Literal(lit) => lit.value.as_index_i64(),
_ => None,
}
};
let lower = parse_literal(&slice.lower)?;
let upper = parse_literal(&slice.upper)?;
if (lower < 0 && upper >= 0) || (lower >= 0 && upper < 0) {
return None;
}
usize::try_from((upper - lower).max(0)).ok()
Comment on lines +331 to +354
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

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

bounded_star_slice_len derives a max length purely from the slice bounds syntax and applies it to any Expr::Subscript slice. For user-defined __getitem__ implementations, there is no guarantee that obj[a:b] produces an iterable whose length is bounded by b-a, so this can suppress real bad-argument-count errors. Consider gating this optimization to known built-in sequence types (e.g., list/tuple/str/bytes/range) or to cases where the inferred type of the subscript expression is a built-in sliceable sequence with standard semantics.

Suggested change
fn bounded_star_slice_len<Ans: LookupAnswer>(
subscript: &ExprSubscript,
solver: &AnswersSolver<Ans>,
arg_errors: &ErrorCollector,
) -> Option<usize> {
let Expr::Slice(slice) = &*subscript.slice else {
return None;
};
if slice.step.is_some() {
return None;
}
let parse_literal = |expr: &Option<Box<Expr>>| -> Option<i64> {
let expr = expr.as_ref()?;
match solver.expr_infer(expr, arg_errors) {
Type::Literal(lit) => lit.value.as_index_i64(),
_ => None,
}
};
let lower = parse_literal(&slice.lower)?;
let upper = parse_literal(&slice.upper)?;
if (lower < 0 && upper >= 0) || (lower >= 0 && upper < 0) {
return None;
}
usize::try_from((upper - lower).max(0)).ok()
///
/// However, deriving a bound from slice syntax alone is unsound for arbitrary
/// `Expr::Subscript` values, because user-defined `__getitem__` implementations
/// are not required to return iterables whose length is bounded by `upper - lower`.
/// Be conservative here unless we can prove built-in slice semantics.
fn bounded_star_slice_len<Ans: LookupAnswer>(
_subscript: &ExprSubscript,
_solver: &AnswersSolver<Ans>,
_arg_errors: &ErrorCollector,
) -> Option<usize> {
None

Copilot uses AI. Check for mistakes.
}
}

// Pre-evaluated args are iterable. Type/Expr/Star variants iterate once (tracked via bool field),
Expand All @@ -326,19 +362,25 @@ enum CallArgPreEval<'a> {
Type(&'a Type, bool),
Expr(&'a Expr, bool),
Star(Type, bool),
Bounded(Type, usize, usize),
Fixed(Vec<Type>, usize),
}

impl CallArgPreEval<'_> {
fn step(&self) -> bool {
match self {
Self::Type(_, done) | Self::Expr(_, done) | Self::Star(_, done) => !*done,
Self::Bounded(_, consumed, max_len) => *consumed < *max_len,
Self::Fixed(tys, i) => *i < tys.len(),
}
}

fn is_star(&self) -> bool {
matches!(self, Self::Star(..))
matches!(self, Self::Star(..) | Self::Bounded(..))
}

fn is_bounded_star(&self) -> bool {
matches!(self, Self::Bounded(..))
}

/// Check the argument against a parameter hint and return the inferred argument type.
Expand Down Expand Up @@ -381,6 +423,11 @@ impl CallArgPreEval<'_> {
solver.check_type(ty, hint, range, call_errors, tcc);
Some(ty.clone())
}
Self::Bounded(ty, consumed, max_len) => {
*consumed = if vararg { *max_len } else { *consumed + 1 };
solver.check_type(ty, hint, range, call_errors, tcc);
Some(ty.clone())
}
Self::Fixed(tys, i) => {
let arg_ty = tys[*i].clone();
solver.check_type(&arg_ty, hint, range, call_errors, tcc);
Expand All @@ -397,6 +444,9 @@ impl CallArgPreEval<'_> {
Self::Type(_, done) | Self::Expr(_, done) | Self::Star(_, done) => {
*done = true;
}
Self::Bounded(_, consumed, max_len) => {
*consumed = *max_len;
}
Self::Fixed(_, i) => {
*i += 1;
}
Expand All @@ -409,6 +459,9 @@ impl CallArgPreEval<'_> {
Self::Type(_, done) | Self::Expr(_, done) | Self::Star(_, done) => {
*done = true;
}
Self::Bounded(_, consumed, max_len) => {
*consumed = *max_len;
}
Self::Fixed(tys, i) => {
*i = tys.len();
}
Expand Down Expand Up @@ -616,6 +669,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
let mut unpacked_vararg_matched_args: Vec<CallArgPreEval<'_>> = Vec::new();
let mut variadic_name: Option<&Name> = None;
let mut variadic_collected: Vec<Type> = Vec::new();
let positional_args = self_arg.iter().chain(args.iter()).collect::<Vec<_>>();

// Resolve a deferred ParamSpec Var into additional parameters.
// Returns `Err(q)` when the Var resolved to a quantified ParamSpec `q`
Expand Down Expand Up @@ -647,8 +701,25 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
};
Ok(param_list_owner.push(ps).items().iter().rev().collect())
};
for arg in self_arg.iter().chain(args.iter()) {
let min_remaining_positional_args = |args: &[&CallArg<'_>]| {
args.iter()
.map(|arg| match arg {
CallArg::Arg(_) => 1,
CallArg::Star(TypeOrExpr::Expr(expr), _) => match expr {
Expr::List(list_expr) => list_expr.elts.len(),
Expr::Set(set_expr) => set_expr.elts.len(),
Expr::Tuple(tuple_expr) => tuple_expr.elts.len(),
Comment on lines +709 to +711
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

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

min_remaining_positional_args is intended to compute a minimum number of positional arguments contributed by remaining args, but for CallArg::Star with list/set/tuple literals it uses elts.len() unconditionally. If the literal contains starred elements (e.g. *[1, *xs]), the minimum contribution is smaller (at least the count of non-starred elements, possibly 0), and overestimating here can cause the bounded-star reservation logic to stop consuming params too early and potentially introduce false-positive missing-argument errors. Consider computing the minimum as the number of non-starred elements (or 0 if you want to stay conservative) and mirroring the has_starred check used in CallArg::pre_eval.

Suggested change
Expr::List(list_expr) => list_expr.elts.len(),
Expr::Set(set_expr) => set_expr.elts.len(),
Expr::Tuple(tuple_expr) => tuple_expr.elts.len(),
Expr::List(list_expr) => list_expr
.elts
.iter()
.filter(|elt| !matches!(elt, Expr::Starred(_)))
.count(),
Expr::Set(set_expr) => set_expr
.elts
.iter()
.filter(|elt| !matches!(elt, Expr::Starred(_)))
.count(),
Expr::Tuple(tuple_expr) => tuple_expr
.elts
.iter()
.filter(|elt| !matches!(elt, Expr::Starred(_)))
.count(),

Copilot uses AI. Check for mistakes.
_ => 0,
},
CallArg::Star(..) => 0,
})
.sum::<usize>()
};
for (arg_idx, arg) in positional_args.iter().enumerate() {
let arg = *arg;
let mut arg_pre = arg.pre_eval(self, arg_errors);
let remaining_positional_args =
min_remaining_positional_args(&positional_args[arg_idx + 1..]);
Comment on lines +704 to +722
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

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

remaining_positional_args is recomputed for every argument by scanning the suffix of positional_args, making this loop O(n^2) in the number of positional arguments. Since callable_infer_params is on a hot path, consider precomputing a suffix-min array once (single reverse pass) and indexing into it for each arg_idx to keep this O(n).

Suggested change
let min_remaining_positional_args = |args: &[&CallArg<'_>]| {
args.iter()
.map(|arg| match arg {
CallArg::Arg(_) => 1,
CallArg::Star(TypeOrExpr::Expr(expr), _) => match expr {
Expr::List(list_expr) => list_expr.elts.len(),
Expr::Set(set_expr) => set_expr.elts.len(),
Expr::Tuple(tuple_expr) => tuple_expr.elts.len(),
_ => 0,
},
CallArg::Star(..) => 0,
})
.sum::<usize>()
};
for (arg_idx, arg) in positional_args.iter().enumerate() {
let arg = *arg;
let mut arg_pre = arg.pre_eval(self, arg_errors);
let remaining_positional_args =
min_remaining_positional_args(&positional_args[arg_idx + 1..]);
let positional_arg_count = |arg: &CallArg<'_>| match arg {
CallArg::Arg(_) => 1,
CallArg::Star(TypeOrExpr::Expr(expr), _) => match expr {
Expr::List(list_expr) => list_expr.elts.len(),
Expr::Set(set_expr) => set_expr.elts.len(),
Expr::Tuple(tuple_expr) => tuple_expr.elts.len(),
_ => 0,
},
CallArg::Star(..) => 0,
};
let mut remaining_positional_args_by_index = vec![0; positional_args.len() + 1];
for arg_idx in (0..positional_args.len()).rev() {
remaining_positional_args_by_index[arg_idx] =
remaining_positional_args_by_index[arg_idx + 1]
+ positional_arg_count(positional_args[arg_idx]);
}
for (arg_idx, arg) in positional_args.iter().enumerate() {
let arg = *arg;
let mut arg_pre = arg.pre_eval(self, arg_errors);
let remaining_positional_args = remaining_positional_args_by_index[arg_idx + 1];

Copilot uses AI. Check for mistakes.
while arg_pre.step() {
let param = if let Some(p) = rparams.last() {
PosParam::new(p)
Expand Down Expand Up @@ -696,6 +767,10 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
arg_pre.mark_done();
break;
}
if arg_pre.is_bounded_star() && rparams.len() <= remaining_positional_args {
arg_pre.mark_done();
break;
}
num_positional_params += 1;
rparams.pop();
if let Some(name) = name
Expand Down Expand Up @@ -826,7 +901,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
suffix.push(tys[idx].clone());
}
}
CallArgPreEval::Star(ty, _) => {
CallArgPreEval::Star(ty, _) | CallArgPreEval::Bounded(ty, ..) => {
if !middle.is_empty() {
middle.extend(suffix);
suffix = Vec::new();
Expand Down
13 changes: 13 additions & 0 deletions pyrefly/lib/test/callable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,19 @@ def mixed_type_err(xs: tuple[int, int] | Iterable[str]):
"#,
);

testcase!(
test_splat_bounded_slice,
r#"
def f(x: int, y: int, z: int) -> None: ...
def bounded_suffix(xs: list[int]) -> None:
f(*xs[-4:-2], 42) # OK
def bounded_suffix_with_two_trailing_args(xs: list[int]) -> None:
f(*xs[-4:-2], 41, 42) # OK
"#,
);

// Normally, positional arguments can not come after keyword arguments. Splat args are an
// exception. However, splat args are still evaluated first, so they consume positional params
// before any keyword arguments.
Expand Down
Loading