Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
89c5b5e
[ty] Model walrus bindings from comprehensions
charliermarsh Jun 30, 2026
ce35be8
[ty] Preserve final comprehension walrus binding
charliermarsh Jun 30, 2026
c28c8df
[ty] Keep unreachable comprehension walruses unbound
charliermarsh Jun 30, 2026
2630dd4
[ty] Report unused comprehension walrus bindings
charliermarsh Jun 30, 2026
3b89505
[ty] Preserve conditional comprehension walrus bindings
charliermarsh Jun 30, 2026
4ae7e03
[ty] Widen loop-carried comprehension walruses
charliermarsh Jun 30, 2026
8d53d3f
[ty] Propagate comprehension walrus usage
charliermarsh Jun 30, 2026
799df55
[ty] Resolve comprehension walrus definitions
charliermarsh Jun 30, 2026
591e139
[ty] Record comprehension walrus review decisions
charliermarsh Jun 30, 2026
aab3a12
[ty] Preserve unreachable comprehension walrus owners
charliermarsh Jun 30, 2026
f6aa100
[ty] Record rejected comprehension walrus findings
charliermarsh Jun 30, 2026
1ffcc39
[ty] Preserve per-walrus unused binding hints
charliermarsh Jun 30, 2026
9f13f0b
[ty] Preserve rejected comprehension filter paths
charliermarsh Jun 30, 2026
26727e5
[ty] Preserve nested comprehension walrus flow
charliermarsh Jun 30, 2026
ddc2357
[ty] Record final comprehension walrus review decision
charliermarsh Jun 30, 2026
5b2c133
[ty] Simplify comprehension walrus widening
charliermarsh Jun 30, 2026
ea68601
[ty] Separate eager comprehension binding synthesis
charliermarsh Jun 30, 2026
110282c
[ty] Clarify comprehension walrus IDE definitions
charliermarsh Jun 30, 2026
cc4cb56
[ty] Unify user-visible definition usage
charliermarsh Jun 30, 2026
3da4c39
[ty] Reuse definition provenance traversal
charliermarsh Jun 30, 2026
db0b45b
[ty] Correct conditional walrus review decision
charliermarsh Jun 30, 2026
52fd12a
[ty] Document comprehension walrus flow
charliermarsh Jun 30, 2026
e6c67d8
[ty] Simplify comprehension walrus flow
charliermarsh Jun 30, 2026
03ed6d0
[ty] Clarify comprehension walrus mdtests
charliermarsh Jun 30, 2026
298bbfa
[ty] Remove review notes
charliermarsh Jun 30, 2026
2dd8602
[ty] Preserve filtered comprehension reachability
charliermarsh Jun 30, 2026
0168dea
[ty] Simplify comprehension walrus tests and docs
charliermarsh Jun 30, 2026
f12b630
[ty] Centralize nested binding bookkeeping
charliermarsh Jun 30, 2026
5bceaf6
[ty] Preserve loop-carried comprehension walruses
charliermarsh Jun 30, 2026
374cc25
[ty] Fix comprehension walrus IDE resolution
charliermarsh Jun 30, 2026
dc69ef2
[ty] Preserve global comprehension walrus definitions
charliermarsh Jun 30, 2026
80f1313
[ty] Document repeated comprehension type limitation
charliermarsh Jun 30, 2026
3588eaf
[ty] Separate comprehension walrus IDE support
charliermarsh Jun 30, 2026
d5e0dbf
[ty] Remove review notes from PR
charliermarsh Jun 30, 2026
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
288 changes: 259 additions & 29 deletions crates/ty_python_core/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ use crate::definition::{
ExceptHandlerDefinitionNodeRef, ForStmtDefinitionNodeRef, ImportDefinitionNodeRef,
ImportFromDefinitionNodeRef, ImportFromSubmoduleDefinitionNodeRef,
LambdaParameterDefinitionNodeRef, LoopHeaderDefinitionNodeRef, LoopStmtRef,
MatchPatternDefinitionNodeRef, NestedBindingsDefinitionKind, ParameterDefinitionNodeRef,
StarImportDefinitionNodeRef, WithItemDefinitionNodeRef,
MatchPatternDefinitionNodeRef, NestedBindingExecution, NestedBindingsDefinitionKind,
ParameterDefinitionNodeRef, StarImportDefinitionNodeRef, WithItemDefinitionNodeRef,
};
use crate::expression::{Expression, ExpressionKind};
use crate::frozen::{FrozenMap, FrozenSet};
Expand Down Expand Up @@ -61,8 +61,8 @@ use crate::statement::StatementInner;
use crate::symbol::{ScopedSymbolId, Symbol};
use crate::unpack::{Unpack, UnpackKind, UnpackPosition, UnpackValue};
use crate::use_def::{
EnclosingSnapshotKey, FlowSnapshot, FutureDefinitions, LiveBinding, PreviousDefinitions,
ScopedDefinitionId, ScopedEnclosingSnapshotId, UseDefMapBuilder,
EnclosingSnapshotKey, FlowSnapshot, FutureDefinitions, LiveBinding, LiveBindingStatus,
PreviousDefinitions, ScopedDefinitionId, ScopedEnclosingSnapshotId, UseDefMapBuilder,
};
use crate::{Db, Statement, StatementNodeKey};
use crate::{
Expand Down Expand Up @@ -1416,9 +1416,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
);
}
Some(CurrentAssignment::Named(named)) => {
// TODO(dhruvmanila): If the current scope is a comprehension, then the
// named expression is implicitly nonlocal. This is yet to be
// implemented.
self.mark_comprehension_named_target(place_id, named.target.range());
self.add_definition(place_id, named);
}
Some(CurrentAssignment::Comprehension {
Expand Down Expand Up @@ -1533,13 +1531,20 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
definitions.len()
};

self.record_definition(place, definition);
self.record_definition(place, definition, None);

(definition, num_definitions)
}

/// Records an already-created definition in the current scope.
fn record_definition(&mut self, place: ScopedPlaceId, definition: Definition<'db>) {
///
/// `binding_shadowing` overrides the ordinary assignment behavior for synthetic bindings.
fn record_definition(
&mut self,
place: ScopedPlaceId,
definition: Definition<'db>,
binding_shadowing: Option<(PreviousDefinitions, FutureDefinitions)>,
) {
let kind = definition.kind(self.db);
let is_loop_header = kind.is_loop_header();
let category = kind.category(self.source_type.is_stub(), self.module);
Expand Down Expand Up @@ -1569,18 +1574,15 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
}
DefinitionCategory::Declaration => use_def.record_declaration(place, definition),
DefinitionCategory::Binding => {
// Loop-header bindings don't shadow prior bindings.
let previous_definitions = if is_loop_header {
PreviousDefinitions::AreKept
} else {
PreviousDefinitions::AreShadowed
};
use_def.record_binding(
place,
definition,
previous_definitions,
let (previous, future) = binding_shadowing.unwrap_or((
if is_loop_header {
PreviousDefinitions::AreKept
} else {
PreviousDefinitions::AreShadowed
},
FutureDefinitions::ShadowThisOne,
);
));
use_def.record_binding(place, definition, previous, future);
if !is_loop_header {
self.delete_associated_bindings(place);
}
Expand Down Expand Up @@ -1785,6 +1787,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
place,
DefinitionKind::NestedBindings(Box::new(NestedBindingsDefinitionKind {
name,
execution: NestedBindingExecution::Lazy,
nested_declarations: declarations,
})),
false,
Expand Down Expand Up @@ -1826,6 +1829,205 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
}
}

/// Records assignment-expression bindings from a comprehension in its containing scope.
///
/// The value expression still belongs to the comprehension scope, so the real definition
/// stays there. The synthetic definition lets the containing scope observe that binding while
/// retaining the comprehension's scope for type inference.
///
/// ```python
/// [(last := item) for item in items]
/// print(last) # `last` is owned by this containing scope.
/// ```
fn synthesize_comprehension_binding_definitions(
&mut self,
nested_bindings: NestedGlobalOrNonlocalDeclarations,
) {
let mut nested_bindings = nested_bindings.into_iter().collect::<Vec<_>>();
nested_bindings.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));

for (name, mut declarations) in nested_bindings {
// Ignore declarations used only to validate `nonlocal` syntax.
declarations.retain(|d| d.is_bound);
declarations.shrink_to_fit();
let Some(first_declaration) = declarations.first().copied() else {
continue;
};

let binding_status = self.comprehension_binding_status(&name, &declarations);

let symbol = self.add_symbol(name.clone());
debug_assert!(
declarations
.iter()
.all(|declaration| declaration.is_global() == first_declaration.is_global())
);
self.forward_comprehension_binding(&name, first_declaration, symbol);

let place: ScopedPlaceId = symbol.into();
self.mark_place_bound(place);
if binding_status == LiveBindingStatus::Unbound {
continue;
}

let definition = Definition::new(
self.db,
self.current_scope_id(),
place,
DefinitionKind::NestedBindings(Box::new(NestedBindingsDefinitionKind {
name,
execution: NestedBindingExecution::Eager,
nested_declarations: declarations,
})),
false,
);
let previous = if binding_status == LiveBindingStatus::Bound {
PreviousDefinitions::AreShadowed
} else {
PreviousDefinitions::AreKept
};
let shadowing = Some((previous, FutureDefinitions::ShadowThisOne));
self.record_definition(place, definition, shadowing);
}
}

/// Summarizes whether the comprehension's live exit paths bind `name`.
///
/// For example, `value` is only possibly bound after this comprehension because the walrus is
/// skipped when `flag` is false:
///
/// ```python
/// [(value := item) if flag else None for item in items]
/// ```
fn comprehension_binding_status(
&mut self,
name: &str,
declarations: &[NestedDeclaration],
) -> LiveBindingStatus {
let mut status = LiveBindingStatus::Unbound;
for declaration in declarations {
let scope_id = declaration.file_scope_id;
let Some(symbol) = self.place_tables[scope_id].symbol_id(name) else {
continue;
};
match self.use_def_maps[scope_id].symbol_live_binding_status(symbol) {
LiveBindingStatus::Bound => return LiveBindingStatus::Bound,
LiveBindingStatus::PossiblyBound => status = LiveBindingStatus::PossiblyBound,
LiveBindingStatus::Unbound => {}
}
}
status
}

/// Passes a walrus binding out through nested comprehensions.
///
/// ```python
/// [[(last := item) for item in row] for row in rows]
/// print(last) # `last` belongs to the scope outside both comprehensions.
/// ```
///
/// Each comprehension passes the binding out one level. This preserves the order and
/// conditions under which the assignment is evaluated.
fn forward_comprehension_binding(
&mut self,
name: &Name,
first_declaration: NestedDeclaration,
symbol: ScopedSymbolId,
) {
if self.scopes[self.current_scope()].kind() != ScopeKind::Comprehension {
return;
}

self.current_scope_info_mut()
.nested_global_or_nonlocal_declarations
.remove(name);

if first_declaration.is_global() {
self.current_place_table_mut()
.symbol_mut(symbol)
.mark_global();
} else {
self.current_place_table_mut()
.symbol_mut(symbol)
.mark_nonlocal();
}
self.current_scope_info_mut()
.this_scope_global_or_nonlocal_declarations
.entry(name.clone())
.or_insert(first_declaration.range);
}

/// Marks a comprehension walrus target as a write to the containing Python scope.
///
/// The iteration variable remains local to the comprehension, while the walrus target does
/// not:
///
/// ```python
/// [(result := item) for item in items]
/// print(result) # valid
/// print(item) # `item` is not defined here
/// ```
fn mark_comprehension_named_target(&mut self, place: ScopedPlaceId, range: TextRange) {
if self.scopes[self.current_scope()].kind() != ScopeKind::Comprehension {
return;
}
if self.semantic_syntax_errors.borrow().iter().any(|error| {
matches!(
error.kind,
SemanticSyntaxErrorKind::ReboundComprehensionVariable
) && error.range == range
}) {
return;
}

let Some(symbol) = place.as_symbol() else {
return;
};
let name = self.current_place_table().symbol(symbol).name().clone();
let Some(containing_scope) = self.scope_stack.iter().rev().find(|scope_info| {
self.scopes[scope_info.file_scope_id].kind() != ScopeKind::Comprehension
}) else {
return;
};

let containing_scope_id = containing_scope.file_scope_id;
let kind = match self.scopes[containing_scope_id].kind() {
ScopeKind::Module => GlobalOrNonlocal::Global,
ScopeKind::Function | ScopeKind::Lambda => {
let is_global = self.place_tables[containing_scope_id]
.symbol_id(&name)
.is_some_and(|symbol| {
self.place_tables[containing_scope_id]
.symbol(symbol)
.is_global()
});
if is_global {
GlobalOrNonlocal::Global
} else {
GlobalOrNonlocal::Nonlocal
}
}
// Assignment expressions are invalid in comprehensions directly contained by these
// scopes. Leave the recovered target local to the comprehension.
ScopeKind::Class | ScopeKind::TypeAlias | ScopeKind::TypeParams => return,
ScopeKind::Comprehension => return,
};

match kind {
GlobalOrNonlocal::Global => self
.current_place_table_mut()
.symbol_mut(symbol)
.mark_global(),
GlobalOrNonlocal::Nonlocal => self
.current_place_table_mut()
.symbol_mut(symbol)
.mark_nonlocal(),
}
self.current_scope_info_mut()
.this_scope_global_or_nonlocal_declarations
.insert(name, range);
}

fn record_expression_narrowing_constraint(
&mut self,
predicate_node: &'ast ast::Expr,
Expand Down Expand Up @@ -2408,8 +2610,9 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
value,
);

let mut filtered_out_paths = Vec::new();
for if_expr in &generator.ifs {
self.visit_comprehension_filter(if_expr);
filtered_out_paths.push(self.visit_comprehension_filter(if_expr));
}

for generator in generators_iter {
Expand All @@ -2426,23 +2629,51 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
);

for if_expr in &generator.ifs {
self.visit_comprehension_filter(if_expr);
filtered_out_paths.push(self.visit_comprehension_filter(if_expr));
}
}

visit_outer_elt(self);
self.pop_scope();
for filtered_out_path in filtered_out_paths {
self.flow_merge(filtered_out_path);
}
let nested_bindings = self.pop_scope();
self.synthesize_comprehension_binding_definitions(nested_bindings);

self.current_assignments = saved_assignments;
}

fn visit_comprehension_filter(&mut self, if_expr: &'ast ast::Expr) {
/// Visits a comprehension filter on its truthy path and returns the filtered-out path.
///
/// A false filter skips the rest of the current iteration, but assignments performed while
/// evaluating the filter remain observable:
///
/// ```python
/// [item for item in items if (last := item)]
/// print(last)
/// ```
fn visit_comprehension_filter(&mut self, if_expr: &'ast ast::Expr) -> FlowSnapshot {
self.visit_expr(if_expr);
let fallback = self.flow_snapshot();
let condition_flow_snapshot = self.flow_snapshot_for_condition(if_expr);
if let Some(truthy) = condition_flow_snapshot.into_truthy() {
self.flow_restore(truthy);
}
let _ = self.record_expression_narrowing_constraint(if_expr);
let filtered_out = if let Some(snapshots) = condition_flow_snapshot.into_branches() {
self.flow_restore(snapshots.truthy);
snapshots.falsy
} else {
fallback
};

let (predicate, narrowing_id) = self.record_expression_narrowing_constraint(if_expr);
let reachability_constraint = self.record_reachability_constraint(predicate);
let included_path = self.flow_snapshot();

self.flow_restore(filtered_out);
self.record_negated_narrowing_constraint(predicate, narrowing_id);
self.record_negated_reachability_constraint(reachability_constraint);
let filtered_out = self.flow_snapshot();

self.flow_restore(included_path);
filtered_out
}

fn declare_parameters(&mut self, parameters: &'ast ast::Parameters) {
Expand Down Expand Up @@ -4416,7 +4647,6 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
}
}
ast::Expr::Named(node) => {
// TODO walrus in comprehensions is implicitly nonlocal
self.visit_expr(&node.value);

// See https://peps.python.org/pep-0572/#differences-between-assignment-expressions-and-assignment-statements
Expand Down
Loading
Loading