From c84089e6442592a9a51032d4cf017fed506f1fbd Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:33:24 +1000 Subject: [PATCH 01/11] add cargo-doc prek task --- .pre-commit-config.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0b87db14c9..fed7009b00 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -75,6 +75,19 @@ repos: files: '^(docs/basedpython/.*\.md|python/basedpython-pygments/.*|scripts/check_by_lexer\.py)$' pass_filenames: false priority: 0 + + - id: cargo-doc + name: check `cargo doc` and `cargo test --doc` + entry: > + bash -c 'RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --document-private-items -p ty_python_semantic + -p ty_python_core -p ty_module_resolver -p ty_site_packages -p ty_combine -p ty_project -p ty_ide -p ty_wasm + -p ty_vendored -p ty_static -p ty -p ty_test -p ruff_db -p ruff_python_formatter + && cargo test --all-features --doc' + types: [rust] + language: system + pass_filenames: false + priority: 0 + # Prettier - repo: https://github.com/rbubley/mirrors-prettier rev: 0ee178619d696787ca73d210cc191d720868c631 # frozen: v3.9.6 From 9967fb4057e66c93d5df5573ee09a9159df67af4 Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:55:12 +1000 Subject: [PATCH 02/11] allow a modifier chain on `init(...)`, and make `private init` a private constructor Co-Authored-By: Claude Opus 5 --- .../src/transforms/init_method.rs | 37 ++ .../by_transforms/src/transforms/modifiers.rs | 30 +- .../test/fixtures/ruff/init_method.by | 9 + .../snapshots/format@init_method.by.snap | 18 + .../src/parser/statement.rs | 66 ++- crates/ruff_python_parser/src/parser/tests.rs | 26 + crates/ruff_python_stdlib/src/basedpython.rs | 31 +- crates/ty/docs/rules.md | 443 ++++++++++-------- crates/ty_ide/src/semantic_tokens.rs | 20 + .../mdtest/basedpython_init_method.md | 21 + .../mdtest/basedpython_visibility.md | 193 ++++++++ .../src/types/diagnostic.rs | 109 +++++ .../src/types/infer/builder.rs | 19 +- .../src/types/infer/builder/function.rs | 38 +- .../src/types/visibility.rs | 80 ++++ docs/basedpython/features/init-method.md | 56 +++ docs/basedpython/features/modifiers.md | 7 + ty.schema.json | 20 + 18 files changed, 1023 insertions(+), 200 deletions(-) diff --git a/crates/by_transforms/src/transforms/init_method.rs b/crates/by_transforms/src/transforms/init_method.rs index ebf343691a..9f74d082f3 100644 --- a/crates/by_transforms/src/transforms/init_method.rs +++ b/crates/by_transforms/src/transforms/init_method.rs @@ -675,6 +675,43 @@ mod tests { ); } + #[test] + fn a_modifier_chain_lowers_alongside_the_shorthand() { + // the modifier's decorator line and the `def __init__` rewrite are + // disjoint edits on the same statement + check( + indoc! {" + class A: + final init(let a: int) + "}, + indoc! {" + from typing import final + class A: + @final + def __init__(self, a: int): + self.a: int = a + "}, + ); + } + + #[test] + fn a_private_constructor_keeps_its_name() { + // `private` on a class member name-mangles it, but python calls + // `__init__` by its exact name — mangling would leave the class with no + // constructor at all. privacy is checked by ty instead + check( + indoc! {" + class A: + private init(let a: int) + "}, + indoc! {" + class A: + def __init__(self, a: int): + self.a: int = a + "}, + ); + } + #[test] fn init_call_inside_method_is_left_alone() { // `init(...)` is the method shorthand only *directly* in a class body. diff --git a/crates/by_transforms/src/transforms/modifiers.rs b/crates/by_transforms/src/transforms/modifiers.rs index fdc8ecf79b..64fd2754ea 100644 --- a/crates/by_transforms/src/transforms/modifiers.rs +++ b/crates/by_transforms/src/transforms/modifiers.rs @@ -30,6 +30,7 @@ use ruff_diagnostics::{Edit, Fix}; use ruff_python_ast::helpers::is_immutable_scalar_default; use ruff_python_ast::visitor::{Visitor, walk_expr, walk_stmt}; use ruff_python_ast::{Expr, Stmt, StmtAnnAssign, StmtClassDef, StmtFunctionDef, StmtTypeAlias}; +use ruff_python_stdlib::basedpython::private_mangles; use ruff_text_size::{Ranged, TextRange, TextSize}; use super::ast_driver::{AstPass, PassContext}; @@ -372,8 +373,15 @@ impl<'src> Modifiers<'src> { if self.class_depth == 0 { self.private_renames.push(func.name.as_str().to_owned()); self.rename_with_underscore(func.name.range()); - } else { - // `private` method gets name-mangled `__name` + } else if private_mangles(func.name.as_str()) { + // a `private` method is name-mangled to `__name`. a + // dunder is left alone: python calls it by its exact + // name, so renaming would change what the method *is* + // rather than who can reach it — and python's own + // mangling rule skips a name with two trailing + // underscores anyway. the one dunder where `private` + // says something, `__init__`, is checked by ty at the + // construction site instead self.rename_with_dunder(func.name.range()); } } @@ -394,6 +402,8 @@ impl<'src> Modifiers<'src> { /// Replace the identifier at `range` with a double-underscore-prefixed /// copy. Used for `private` class members so Python's name-mangling /// applies and the symbol is hidden from subclass scope + /// + /// Only call this for a name [`private_mangles`] accepts. fn rename_with_dunder(&mut self, range: TextRange) { let original = self.src(range).to_owned(); self.edits.push(Fix::safe_edit(Edit::range_replacement( @@ -1585,6 +1595,22 @@ mod tests { check("private def helper(): ...\n", "def _helper(): ...\n"); } + #[test] + fn a_private_method_of_only_underscores_keeps_its_name() { + // `__` + `_` is `___`, and python mangles only a name with at most one + // trailing underscore, so the rename would hide nothing + check( + indoc! {" + class A: + private def _(self): ... + "}, + indoc! {" + class A: + def _(self): ... + "}, + ); + } + #[test] fn private_def_call_site_renamed() { check( diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/init_method.by b/crates/ruff_python_formatter/resources/test/fixtures/ruff/init_method.by index 71e0eca62b..9b66296126 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/init_method.by +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/init_method.by @@ -20,6 +20,15 @@ class PlainParams: init(self, a: int, let b: int) +class DefinitionModifiers: + private init(self, let a: int) + + +class ChainedDefinitionModifiers: + private final init(self, let a: int): + print(a) + + class Defaults: init(self, let a: int = 0, var b: list[int] = [], *args: int, **kwargs: str) diff --git a/crates/ruff_python_formatter/tests/snapshots/format@init_method.by.snap b/crates/ruff_python_formatter/tests/snapshots/format@init_method.by.snap index 5595919a96..8ebac7ff2f 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@init_method.by.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@init_method.by.snap @@ -26,6 +26,15 @@ class PlainParams: init(self, a: int, let b: int) +class DefinitionModifiers: + private init(self, let a: int) + + +class ChainedDefinitionModifiers: + private final init(self, let a: int): + print(a) + + class Defaults: init(self, let a: int = 0, var b: list[int] = [], *args: int, **kwargs: str) @@ -89,6 +98,15 @@ class PlainParams: init(self, a: int, let b: int) +class DefinitionModifiers: + private init(self, let a: int) + + +class ChainedDefinitionModifiers: + private final init(self, let a: int): + print(a) + + class Defaults: init(self, let a: int = 0, var b: list[int] = [], *args: int, **kwargs: str) diff --git a/crates/ruff_python_parser/src/parser/statement.rs b/crates/ruff_python_parser/src/parser/statement.rs index 8bfcf891c6..50e36672b5 100644 --- a/crates/ruff_python_parser/src/parser/statement.rs +++ b/crates/ruff_python_parser/src/parser/statement.rs @@ -692,7 +692,7 @@ impl<'src> Parser<'src> { self.error_if_not_basedpython( "`init(...)` method shorthand is not valid in .py files".to_string(), ); - return Stmt::FunctionDef(self.parse_init_method(start)); + return Stmt::FunctionDef(self.parse_init_method(start, DecoratorList::new())); } // Handle basedpython modifier keywords and introducer keywords: @@ -994,6 +994,15 @@ impl<'src> Parser<'src> { )); return Some(self.parse_with_modifier(start, DecoratorList::new())); } + // `private init(...)`, `final init(...)` — the `init` shorthand + // is a `def __init__`, so a modifier chain reaches it the same + // way it reaches any other method + if self.class_body_depth > 0 && text == "init" && following == TokenKind::Lpar { + self.error_if_not_basedpython(format!( + "`{kw}` is a basedpython modifier and is not valid in .py files" + )); + return Some(self.parse_with_modifier(start, DecoratorList::new())); + } return match following { TokenKind::Equal if idx > 0 => { self.error_if_not_basedpython(format!( @@ -1107,6 +1116,15 @@ impl<'src> Parser<'src> { matches!(self.src_text(range), "private" | "public" | "export") } + /// Whether the parser is sitting on the `init(...)` constructor shorthand: + /// the name `init` followed by a parameter list, inside a class body. + fn at_init_shorthand(&mut self) -> bool { + self.class_body_depth > 0 + && self.at(TokenKind::Name) + && self.src_text(self.current_token_range()) == "init" + && self.peek() == TokenKind::Lpar + } + /// Parses a basedpython modifier keyword statement such as `final class Foo:`, /// `static def foo():`, or `class def f(cls):`. /// @@ -1230,7 +1248,8 @@ impl<'src> Parser<'src> { // modifier that reads on the other kind decides nothing here, and was // being carried into a lowering that had no arm for it and left the // keyword in the emitted python - let target = if self.at(TokenKind::Async) || self.at(TokenKind::Def) { + let at_init = self.at_init_shorthand(); + let target = if self.at(TokenKind::Async) || self.at(TokenKind::Def) || at_init { ModifierTarget::Function } else { ModifierTarget::Class @@ -1259,6 +1278,33 @@ impl<'src> Parser<'src> { ); } + if at_init { + // a constructor is neither a free function nor a class-level one, so + // the two modifiers that say which of those a `def` is have nothing + // to say here + let misplaced_on_init: Vec = decorators + .iter() + .filter(|dec| match &dec.expression { + Expr::Name(name) => { + name.ctx == ExprContext::Invalid + && matches!(name.id.as_str(), "static" | "classmethod") + } + _ => false, + }) + .map(Ranged::range) + .collect(); + for range in misplaced_on_init { + let kw = self.src_text(range).trim_end().to_owned(); + self.add_error( + ParseErrorType::OtherError(format!( + "`{kw}` is not a modifier on an `init(...)` constructor" + )), + range, + ); + } + return Stmt::FunctionDef(self.parse_init_method(start, decorators)); + } + if self.at(TokenKind::Async) { // `abstract async def`, `final async def`, … — the modifier applies // to an async function @@ -5252,11 +5298,19 @@ impl<'src> Parser<'src> { /// The function is named `__init__` directly so ty's semantic analysis (which /// scans `__init__` body for `self.X = ...` assignments) sees the synthesised /// body statements created from each `let` parameter - fn parse_init_method(&mut self, start: TextSize) -> ast::StmtFunctionDef { + /// + /// `decorators` holds any modifier chain written in front of the keyword + /// (`private init(...)`, `final init(...)`); the `__init_method__` marker is + /// appended after it, so the modifiers keep their source order. + fn parse_init_method( + &mut self, + start: TextSize, + mut decorator_list: DecoratorList, + ) -> ast::StmtFunctionDef { let init_range = self.current_token_range(); self.bump(TokenKind::Name); // consume "init" - let decorator = ast::Decorator { + decorator_list.push(ast::Decorator { expression: Expr::Name(ast::ExprName { id: Name::new_static("__init_method__"), ctx: ExprContext::Invalid, @@ -5265,7 +5319,7 @@ impl<'src> Parser<'src> { }), range: init_range, node_index: AtomicNodeIndex::NONE, - }; + }); let name = ast::Identifier { id: Name::new_static("__init__"), @@ -5334,7 +5388,7 @@ impl<'src> Parser<'src> { type_params: None, parameters: Box::new(parameters), body, - decorator_list: vec![decorator].into(), + decorator_list, is_async: false, // `init(...)` is a `__init__`, which returns `None`. synthesise the // annotation (zero-width, after the parameter list) so ty sees diff --git a/crates/ruff_python_parser/src/parser/tests.rs b/crates/ruff_python_parser/src/parser/tests.rs index 063a8b4114..82f693be85 100644 --- a/crates/ruff_python_parser/src/parser/tests.rs +++ b/crates/ruff_python_parser/src/parser/tests.rs @@ -3038,6 +3038,32 @@ fn basedpython_destructuring_let_rejects_plain_equals() { } } +/// a modifier chain in front of `init(...)` reaches the constructor rather than +/// being read as a statement of its own +#[test] +fn basedpython_init_method_takes_a_modifier_chain() { + let parsed = + parse_basedpython_module_with_errors("class C:\n private final init(let a: int)\n"); + assert_eq!(parsed.errors(), &[], "expected a clean parse"); + + let Some(Stmt::ClassDef(class)) = parsed.suite().first() else { + panic!("expected a class, got {:?}", parsed.suite()); + }; + let Some(Stmt::FunctionDef(function)) = class.body.first() else { + panic!("expected a function, got {:?}", class.body); + }; + assert_eq!(function.name.as_str(), "__init__"); + let markers: Vec<&str> = function + .decorator_list + .iter() + .filter_map(|decorator| match &decorator.expression { + Expr::Name(name) => Some(name.id.as_str()), + _ => None, + }) + .collect(); + assert_eq!(markers, ["private", "final", "__init_method__"]); +} + /// every parameter of an `init(...)` becomes a field of the same name, which a /// pattern has none of #[test] diff --git a/crates/ruff_python_stdlib/src/basedpython.rs b/crates/ruff_python_stdlib/src/basedpython.rs index 0a4b9cc7a3..b8e0b2365b 100644 --- a/crates/ruff_python_stdlib/src/basedpython.rs +++ b/crates/ruff_python_stdlib/src/basedpython.rs @@ -97,9 +97,26 @@ pub fn implicit_typing_name(name: &str) -> Option<&'static str> { .map(|index| IMPLICIT_TYPING_NAMES[index]) } +/// whether `private` on a class member named `name` actually hides it — that is, +/// whether python would name-mangle the `__{name}` the lowering renames it to +/// +/// python's rule is two or more leading underscores and at most one trailing +/// one, so a name that already ends in `__` is looked up verbatim. every name of +/// two or more underscores already ends in `__`; the one that does not is `_`, +/// which prefixes to `___` and is looked up verbatim just the same +/// +/// this is the single source of truth for the rule. the transpiler skips the +/// rename where it answers `false`, and the type checker reports the modifier as +/// having no effect at exactly the same names +pub fn private_mangles(name: &str) -> bool { + !name.ends_with("__") && name != "_" +} + #[cfg(test)] mod tests { - use super::{IMPLICIT_TYPING_NAMES, implicit_typing_name, is_implicit_typing_name}; + use super::{ + IMPLICIT_TYPING_NAMES, implicit_typing_name, is_implicit_typing_name, private_mangles, + }; #[test] fn implicit_typing_names_sorted() { @@ -125,4 +142,16 @@ mod tests { assert_eq!(entry, Some("Mapping")); assert_eq!(implicit_typing_name("cast"), None); } + + #[test] + fn a_name_python_looks_up_verbatim_is_not_mangled() { + assert!(private_mangles("helper")); + assert!(private_mangles("trailing_")); + // `__` + the name is what python is asked about, so a name that is only + // underscores lands on the same rule a dunder does + assert!(!private_mangles("_")); + assert!(!private_mangles("__")); + assert!(!private_mangles("__init__")); + assert!(!private_mangles("__repr__")); + } } diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index ff4daac4a3..039f5a9f55 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -8,7 +8,7 @@ Default level: error · Added in 0.0.64 · Related issues · -View source +View source @@ -44,7 +44,7 @@ class Base(ABC): Default level: error · Added in 0.0.13 · Related issues · -View source +View source @@ -90,7 +90,7 @@ class Derived(Base): # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -123,7 +123,7 @@ f(1, b=s1) # ok — explicit Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.39 · Related issues · -View source +View source @@ -159,7 +159,7 @@ report(Celsius()) # error: two conversions apply Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.3 · Related issues · -View source +View source @@ -194,7 +194,7 @@ extension list: Default level: warn · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -258,7 +258,7 @@ class SubProto(BaseProto, Protocol): Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -340,7 +340,7 @@ value = unknown # ty: ignore[unresolved-reference] Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -383,7 +383,7 @@ a4 = True + 1 # ok — a boolean used as a boolean Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -435,7 +435,7 @@ Foo.method() # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -463,7 +463,7 @@ Calling a non-callable object will raise a `TypeError` at runtime. Default level: error · Added in 0.0.7 · Related issues · -View source +View source @@ -498,7 +498,7 @@ def f(x: object): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -531,7 +531,7 @@ a = 1 # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -565,7 +565,7 @@ class C(A, B): ... # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -599,7 +599,7 @@ class B(A): ... # error Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -644,7 +644,7 @@ type Tree = int | list[Tree] # valid recursive alias Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -680,7 +680,7 @@ class Example: Default level: warn · Added in 0.0.1-alpha.16 · Related issues · -View source +View source @@ -719,7 +719,7 @@ old_func() # error: [deprecated] Default level: ignore · Added in 0.0.78 · Related issues · -View source +View source @@ -891,7 +891,7 @@ soundness checks from their type checker, and it may have false positives in som Default level: error · Level under ty-compatible: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -923,7 +923,7 @@ This rule is currently disabled by default because of the number of false positi Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -954,7 +954,7 @@ class B(A, A): ... # error Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -994,7 +994,7 @@ class A: # error Default level: ignore · Added in 0.0.73 · Related issues · -View source +View source @@ -1105,7 +1105,7 @@ Python code. Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -1154,7 +1154,7 @@ def bar() -> str: # error: [empty-body] Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -1215,7 +1215,7 @@ def h(x: object): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.3 · Related issues · -View source +View source @@ -1317,7 +1317,7 @@ def foo() -> "intt\b": ... # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1356,7 +1356,7 @@ def f(local fn: () -> None): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1397,7 +1397,7 @@ for x in [1, 2, 3]: Default level: warn · Added in 0.0.50 · Related issues · -View source +View source @@ -1437,7 +1437,7 @@ def g(value: ~A) -> None: ... # error: [experimental-syntax] Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -1471,7 +1471,7 @@ def my_function() -> int: Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.40 · Related issues · -View source +View source @@ -1506,7 +1506,7 @@ let a = 1 Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -1623,7 +1623,7 @@ def test() -> "Literal[5]": Default level: ignore · basedpython only, so absent under ty-compatible · Added in 0.0.72 · Related issues · -View source +View source @@ -1676,7 +1676,7 @@ unpacking — is not a declaration, and is never reported. Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.68 · Related issues · -View source +View source @@ -1751,7 +1751,7 @@ print(Labelled) # warning: prints `` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1787,7 +1787,7 @@ class C(A, B): ... # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1816,7 +1816,7 @@ t[3] # error Default level: warn · Added in 0.0.1-alpha.33 · Related issues · -View source +View source @@ -1847,13 +1847,49 @@ MyClass = final(type("MyClass", (), {})) # error class MyClass: ... ``` +## `ineffective-private` + + +Default level: error · basedpython only, so absent under ty-compatible · +Added in 0.0.79 · +Related issues · +View source + + + +**What it does** + +Checks for the `private` modifier on a class member whose name it cannot +hide. + +**Why is this bad?** + +`private` hides a class member by renaming it so python's name-mangling +applies, and python mangles only a name with at most one trailing +underscore. A dunder is therefore left with the name it was written +with, and the modifier does nothing — which is worse than an error, +because the declaration reads as though the member were hidden. + +`init` is the exception. It is the one dunder `private` says something +about, and it is enforced at the construction site rather than by hiding +a name: see [[`private-constructor`](#private-constructor)](private-constructor.md). + +**Example** + + +```by +class Point: + private def __repr__(self) -> str: # error: `private` does nothing here + return "Point()" +``` + ## `instance-layout-conflict` Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -1948,7 +1984,7 @@ will produce instances with an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1980,7 +2016,7 @@ func("foo") # error: [invalid-argument-type] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2010,7 +2046,7 @@ a: int = "" # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2091,7 +2127,7 @@ box.value = 1 # okay Default level: error · Added in 0.0.33 · Related issues · -View source +View source @@ -2136,7 +2172,7 @@ class Sub(Base): Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -2178,7 +2214,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2205,7 +2241,7 @@ class A(42): ... # error: [invalid-base] Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.79 · Related issues · -View source +View source @@ -2243,7 +2279,7 @@ build: # error: `build` is an experimental feature, and is off Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.5 · Related issues · -View source +View source @@ -2280,7 +2316,7 @@ extension str(A): # error: `str` does not answer every member of `A` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2309,7 +2345,7 @@ with 1: # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.39 · Related issues · -View source +View source @@ -2342,7 +2378,7 @@ class Fahrenheit: Default level: error · Added in 0.0.12 · Related issues · -View source +View source @@ -2395,7 +2431,7 @@ See: Default level: error · Added in 0.0.13 · Related issues · -View source +View source @@ -2431,7 +2467,7 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2463,7 +2499,7 @@ a: str # error Default level: warn · Added in 0.0.20 · Related issues · -View source +View source @@ -2519,7 +2555,7 @@ class Pet(Enum): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2584,7 +2620,7 @@ This rule corresponds to Ruff's Default level: error · Added in 0.0.1-alpha.28 · Related issues · -View source +View source @@ -2638,7 +2674,7 @@ class D(A): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.3 · Related issues · -View source +View source @@ -2671,7 +2707,7 @@ extension list[T: int]: # error: `list` declares no type parameter `T` Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.4 · Related issues · -View source +View source @@ -2700,7 +2736,7 @@ Author.objects.filter(name__startswith=1) # error: lookup wants `str` Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.36 · Related issues · -View source +View source @@ -2736,7 +2772,7 @@ def test_user(user: int) -> None: # error: fixture provides `str` Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.68 · Related issues · -View source +View source @@ -2784,7 +2820,7 @@ f"{'name':>10}" # ok Default level: error · Added in 0.0.1-alpha.35 · Related issues · -View source +View source @@ -2834,7 +2870,7 @@ class NonFrozenChild(FrozenBase): # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2889,7 +2925,7 @@ class E(Generic[V]): Default level: error · Added in 0.0.12 · Related issues · -View source +View source @@ -2984,7 +3020,7 @@ a = 20 / 0 # type: ignore Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source @@ -3031,7 +3067,7 @@ carol = Person(name="Carol", aeg=25) # typo! Default level: warn · Added in 0.0.15 · Related issues · -View source +View source @@ -3092,7 +3128,7 @@ def f(x, y, /): # Python 3.8+ syntax Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3132,7 +3168,7 @@ def f(t: TypeVar("U")): ... # ty: ignore[invalid-type-form] Default level: error · Added in 0.0.18 · Related issues · -View source +View source @@ -3183,7 +3219,7 @@ match object(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3217,7 +3253,7 @@ class B(metaclass=42): ... # error Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -3334,7 +3370,7 @@ Correct use of `@override` is enforced by ty's [`invalid-explicit-override`](#in Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.72 · Related issues · -View source +View source @@ -3377,7 +3413,7 @@ implements Backend # error: `Backend` is not a protocol Default level: error · Added in 0.0.72 · Related issues · -View source +View source @@ -3415,7 +3451,7 @@ from module import missing # error Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -3480,7 +3516,7 @@ TypeError: typing.ClassVar[int] is not valid as type argument Default level: warn · Added in 0.0.31 · Related issues · -View source +View source @@ -3526,7 +3562,7 @@ admin[0] # "Alice" Default level: error · Added in 0.0.1-alpha.27 · Related issues · -View source +View source @@ -3564,7 +3600,7 @@ Baz = NewType("Baz", int | str) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3621,7 +3657,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3649,7 +3685,7 @@ def f(a: int = ""): ... # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.36 · Related issues · -View source +View source @@ -3682,7 +3718,7 @@ def test_add(a: int, b: int) -> None: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3718,7 +3754,7 @@ P2 = ParamSpec() # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3770,7 +3806,7 @@ Declare the type variable with `TypeVar("T", covariant=True)` instead. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3841,7 +3877,7 @@ def g(): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.37 · Related issues · -View source +View source @@ -3869,7 +3905,7 @@ def f() raises int: # error: `int` is not an exception Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.36 · Related issues · -View source +View source @@ -3902,7 +3938,7 @@ if m := re.match("(a)(b)", "ab"): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -3934,7 +3970,7 @@ type Alias[reified T] = list[T] # error: an alias's parameters are erased Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4080,7 +4116,7 @@ def detail(request, pk: int): ... # ok Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.79 · Related issues · -View source +View source @@ -4119,7 +4155,7 @@ import "data/missing.json" as missing Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4227,7 +4263,7 @@ class C: ... Default level: error · Added in 0.0.10 · Related issues · -View source +View source @@ -4278,7 +4314,7 @@ class MyClass: Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -4324,7 +4360,7 @@ InvalidAlias = TypeAliasType("InvalidAlias", list[T], type_params=(list[T],)) # Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -4390,7 +4426,7 @@ Bar[int] # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4423,7 +4459,7 @@ TYPE_CHECKING = "" # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4458,7 +4494,7 @@ b: Annotated[int] # error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -4515,7 +4551,7 @@ class C: Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -4559,7 +4595,7 @@ def g[U, T: U](): ... # error: [invalid-type-variable-bound] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4616,7 +4652,7 @@ V = TypeVar("V", list[int], int) # valid constrained Type Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -4658,7 +4694,7 @@ U = TypeVar("U", int, str, default=bytes) # error: [invalid-type-variable-defau Default level: error · Added in 0.0.28 · Related issues · -View source +View source @@ -4694,7 +4730,7 @@ class Child(Base): Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -4735,7 +4771,7 @@ def f(options: dict[str, object]): Default level: error · Added in 0.0.9 · Related issues · -View source +View source @@ -4769,7 +4805,7 @@ class Foo(TypedDict): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -4810,7 +4846,7 @@ type Alias[out T] = list[T] # error: `list` is invariant Default level: error · Added in 0.0.25 · Related issues · -View source +View source @@ -4844,7 +4880,7 @@ def gen() -> Iterator[int]: Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -4910,7 +4946,7 @@ def h(arg2: type): Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -4959,7 +4995,7 @@ def g(arg: object): Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.36 · Related issues · -View source +View source @@ -4990,7 +5026,7 @@ def f(s: str): Default level: warn · Added in 0.0.30 · Related issues · -View source +View source @@ -5032,7 +5068,7 @@ Movie = TypedDict("Film", {"title": str}) # error: [mismatched-type-name] Default level: warn · Added in 0.0.1-alpha.39 · Related issues · -View source +View source @@ -5100,7 +5136,7 @@ and nothing is reported. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5131,7 +5167,7 @@ func() # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -5162,7 +5198,7 @@ f(1) # ok — `s` is passed implicitly Default level: ignore · Preview (since 0.0.76) · Related issues · -View source +View source @@ -5251,7 +5287,7 @@ Add `urllib3` to `project.dependencies` if your code imports it directly. Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.4 · Related issues · -View source +View source @@ -5279,7 +5315,7 @@ from django.db import models # warning: install `django-stubs` for precise type Default level: error · Level under ty-compatible: ignore · Added in 0.0.41 · Related issues · -View source +View source @@ -5340,7 +5376,7 @@ class ExplicitChild(Parent): Default level: error · Added in 0.0.75 · Related issues · -View source +View source @@ -5427,7 +5463,7 @@ class Item: Default level: error · Level under ty-compatible: ignore · Added in 0.0.45 · Related issues · -View source +View source @@ -5465,7 +5501,7 @@ def handle(m: re.Match[str]) -> str: Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -5504,7 +5540,7 @@ alice["age"] # KeyError Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5538,7 +5574,7 @@ def f(a: int | None): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5576,7 +5612,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.30 · Related issues · -View source +View source @@ -5613,7 +5649,7 @@ class Sub(Super): ... # error: [non-callable-init-subclass] Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.39 · Related issues · -View source +View source @@ -5644,7 +5680,7 @@ def f(x: int | str) -> int: Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -5673,7 +5709,7 @@ def f(a: object): Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -5717,7 +5753,7 @@ def g(o: object, shape: Shape): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5746,7 +5782,7 @@ for i in 34: # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5774,7 +5810,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5803,7 +5839,7 @@ def f(once done: () -> None): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5831,7 +5867,7 @@ def f(once done: () -> None): Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -5869,7 +5905,7 @@ def f(x: int?): Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -5924,7 +5960,7 @@ def g(name: str | None): Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -5961,7 +5997,7 @@ class B(A): Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -5998,7 +6034,7 @@ class B(A): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.38 · Related issues · -View source +View source @@ -6041,7 +6077,7 @@ def main(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -6072,7 +6108,7 @@ f(1, x=2) # error Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -6103,7 +6139,7 @@ f(x=1) # error Default level: error · Level under ty-compatible: ignore · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -6141,7 +6177,7 @@ A.c # error Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -6179,7 +6215,7 @@ A()[0] # error Default level: error · Level under ty-compatible: ignore · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -6223,7 +6259,7 @@ from module import a # error Default level: warn · Added in 0.0.23 · Related issues · -View source +View source @@ -6255,7 +6291,7 @@ html.parser # error Default level: error · Level under ty-compatible: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -6285,13 +6321,52 @@ for i in range(int(input())): print(x) # error ``` +## `private-constructor` + + +Default level: error · basedpython only, so absent under ty-compatible · +Added in 0.0.79 · +Related issues · +View source + + + +**What it does** + +Checks for constructing a class whose `init` is declared `private`, from +outside that class's own body. + +**Why is this bad?** + +A `private` constructor says the class decides how its instances are +made: callers go through a factory the class provides, which can pick a +subclass, return a cached instance, or reject the arguments. Calling the +constructor directly bypasses that. + +A subclass is outside the class's body too, so it cannot construct the +base either. + +**Example** + + +```by +class Id: + private init(let raw: str) + + @classmethod + def parse(cls, text: str) -> Id: + return Id(text.strip()) # ok: inside the class + +Id("x") # error: `Id`'s constructor is private +``` + ## `private-import` Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -6323,7 +6398,7 @@ from helpers import Key # error: `Key` is private to `helpers` Default level: warn · Added in 0.0.60 · Related issues · -View source +View source @@ -6398,7 +6473,7 @@ def test() -> "int": Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -6440,7 +6515,7 @@ def g(a: bool | None): Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -6475,7 +6550,7 @@ cast(int, f()) # error Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -6529,7 +6604,7 @@ if sys.version_info >= (3, 12): # ok — artificially constant Default level: warn · Added in 0.0.18 · Related issues · -View source +View source @@ -6567,7 +6642,7 @@ class C: Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -6622,7 +6697,7 @@ class Sub(Base): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -6671,7 +6746,7 @@ def f(value: int | str) -> int: Default level: error · Added in 0.0.71 · Related issues · -View source +View source @@ -6735,7 +6810,7 @@ def g(values: tuple[int, ...]) -> None: Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.3 · Related issues · -View source +View source @@ -6768,7 +6843,7 @@ class C: Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.72 · Related issues · -View source +View source @@ -6804,7 +6879,7 @@ class C[T]: Default level: warn · Added in 0.0.71 · Related issues · -View source +View source @@ -6847,7 +6922,7 @@ def build(t: Tag) -> None: Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -6891,7 +6966,7 @@ class Outer[T]: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -6925,7 +7000,7 @@ static_assert(int(2.0 * 3.0) == 6) # error Default level: warn · Added in 0.0.39 · Related issues · -View source +View source @@ -6977,7 +7052,7 @@ limitation. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7011,7 +7086,7 @@ class B(A): ... # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7043,7 +7118,7 @@ class Circle(Shape): ... # error: `Shape` is sealed in another workspace Default level: error · Added in 0.0.1-alpha.30 · Related issues · -View source +View source @@ -7152,7 +7227,7 @@ class Book: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7182,7 +7257,7 @@ f("foo") # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7221,7 +7296,7 @@ def find(items: list[int]) -> int: Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7268,7 +7343,7 @@ g: Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7303,7 +7378,7 @@ f: # error: the block returns `None`, not `str` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7342,7 +7417,7 @@ def _(x: int): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.4 · Related issues · -View source +View source @@ -7373,7 +7448,7 @@ class User(BaseModel): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7432,7 +7507,7 @@ class A: Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -7505,7 +7580,7 @@ the project registers with `@register.simple_block_tag`. Default level: warn · Added in 0.0.1-alpha.39 · Related issues · -View source +View source @@ -7571,7 +7646,7 @@ what the projects depending on it read. Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.37 · Related issues · -View source +View source @@ -7600,7 +7675,7 @@ def f() raises TypeError: Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7629,7 +7704,7 @@ reveal_type(1) # revealed: Literal[1] Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.37 · Related issues · -View source +View source @@ -7659,7 +7734,7 @@ def main(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7690,7 +7765,7 @@ f(x=1, y=2) # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.36 · Related issues · -View source +View source @@ -7901,7 +7976,7 @@ page does not render at all. Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.72 · Related issues · -View source +View source @@ -7935,7 +8010,7 @@ implements Backend # error: this module does not answer `Backend` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7968,7 +8043,7 @@ A().foo # error Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -8043,7 +8118,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -8071,7 +8146,7 @@ import foo # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -8101,7 +8176,7 @@ def check(value: int | None) -> asserts values: # error: `values` is nothing Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -8219,7 +8294,7 @@ is one whose template set cannot be established. Default level: ignore · Added in 0.0.73 · Related issues · -View source +View source @@ -8347,7 +8422,7 @@ Python code. Default level: error · Added in 0.0.71 · Related issues · -View source +View source @@ -8388,7 +8463,7 @@ def f(a: object, b: int, c: Any): Default level: ignore · Added in 0.0.70 · Related issues · -View source +View source @@ -8533,7 +8608,7 @@ Python code. Default level: ignore · Added in 0.0.70 · Related issues · -View source +View source @@ -8680,7 +8755,7 @@ generator boundaries. Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.3 · Related issues · -View source +View source @@ -8730,7 +8805,7 @@ A() # error: nothing says which specialization this is Default level: warn · Added in 0.0.1-alpha.7 · Related issues · -View source +View source @@ -8776,7 +8851,7 @@ class D(C): ... # error: [unsupported-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -8825,7 +8900,7 @@ b1 < b2 < b1 # error Default level: warn · Level under ty-compatible: ignore · Added in 0.0.12 · Related issues · -View source +View source @@ -8870,7 +8945,7 @@ def factory(base: type[Base]) -> type: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -8902,7 +8977,7 @@ A() + A() # error Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.79 · Related issues · -View source +View source @@ -8945,7 +9020,7 @@ reveal_type(project.root) # revealed: "." Default level: warn · Added in 0.0.21 · Related issues · -View source +View source @@ -9024,7 +9099,7 @@ to `false` to prevent this rule from reporting unused `type: ignore` comments. Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.71 · Related issues · -View source +View source @@ -9110,7 +9185,7 @@ to `false`. Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -9189,7 +9264,7 @@ def foo(x: int | str) -> int | str: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty_ide/src/semantic_tokens.rs b/crates/ty_ide/src/semantic_tokens.rs index 8605dba713..ef5532d9e8 100644 --- a/crates/ty_ide/src/semantic_tokens.rs +++ b/crates/ty_ide/src/semantic_tokens.rs @@ -6519,6 +6519,26 @@ class A: "#); } + #[test] + fn semantic_tokens_init_shorthand_with_a_modifier() { + let test = SemanticTokenTest::new_by( + " +class A: + private init(a: int) +", + ); + + let tokens = test.highlight_file(); + + assert_snapshot!(test.to_snapshot(&tokens), @r#" + "A" @ 7..8: Class [definition] + "private" @ 14..21: Keyword + "init" @ 22..26: Keyword + "a" @ 27..28: Parameter [definition] + "int" @ 30..33: Class + "#); + } + #[test] fn semantic_tokens_init_let_parameter() { // a `let` parameter is promoted to a `self.a` assignment synthesised diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_init_method.md b/crates/ty_python_semantic/resources/mdtest/basedpython_init_method.md index da3df54f0d..9b4d27402f 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_init_method.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_init_method.md @@ -154,6 +154,27 @@ x = A(1) x.a # error: [unresolved-attribute] ``` +## a modifier chain may precede `init` + +`init(...)` is a `def __init__`, so a modifier written in front of it applies exactly as it would to +a `def`: + +```by +class A: + final init(let n: int) + +reveal_type(A(1).n) # revealed: int +``` + +`static` and the `class def` classmethod modifier say which kind of function a `def` is, and a +constructor is neither of those, so they are rejected: + +```by +class B: + # error: [invalid-syntax] "`static` is not a modifier on an `init(...)` constructor" + static init() +``` + ## call diagnostics name the class `init` has no `__init__` in the source to point at, so a bad constructor call names the class the diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_visibility.md b/crates/ty_python_semantic/resources/mdtest/basedpython_visibility.md index 997fd08e65..ac5bc36c56 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_visibility.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_visibility.md @@ -5,6 +5,11 @@ symbol to the module's generated `__all__`, and `private` renames it with an und inside a class body, name-mangles it with `__`). they carry no type-level effect — the decorated class or function keeps its ordinary type rather than being erased to `Unknown`. +a dunder is the exception: python looks one up by its exact name, and mangles only names with at +most one trailing underscore, so renaming would change what the method *is* rather than who can +reach it. `private` on one is therefore reported as having no effect — except on `__init__`, the one +dunder where it says something, which is checked at the construction site instead. + ## a private class keeps its type ```by @@ -49,6 +54,194 @@ private final class Sealed: reveal_type(Sealed().n) # revealed: int ``` +## `private` on a name it cannot hide + +`private` hides a member by renaming it so python's name-mangling applies. a name python looks up +verbatim is left as written, so the modifier would silently do nothing, and it is reported instead. + +```by +class Point: + private def __repr__(self) -> str: # error: [ineffective-private] + return "Point()" + + # `__` + `_` is `___`, which python also looks up verbatim + private def _(self): ... # error: [ineffective-private] +``` + +a name with at most one trailing underscore is hidden as usual, and `__init__` is the one dunder +`private` does say something about. + +```by +class Id: + private init() + + private def helper(self): ... + private def unhide_(self): ... +``` + +## a `private` constructor may only be called by its own class + +name-mangling is how `private` hides a class member, but python calls a constructor by its exact +name, so there is no spelling that would hide `__init__` and still leave it a constructor. a +`private init` is enforced at the construction site instead: the class's own body may construct it, +and nothing else may. that is what lets a class hand out its instances through a factory of its own. + +```by +class Id: + private init(let raw: str) + + @classmethod + def parse(cls, text: str) -> Id: + return Id(text.strip()) + +reveal_type(Id.parse(" a ").raw) # revealed: str + +made = Id("a") # error: [private-constructor] +``` + +## the diagnostic points back at the declaration + +the class that drew the boundary is named at the construction site and annotated where it declared +the constructor. + +```by +class Id: + private init(let raw: str) + +made = Id("a") # snapshot +``` + +```snapshot +error[private-constructor]: Cannot construct `Id`: its constructor is private + --> src/mdtest_snippet.by:4:8 + | +4 | made = Id("a") # snapshot + | ^^^^^^^ +info: Only code inside `Id` may construct it + --> src/mdtest_snippet.by:2:13 + | +2 | private init(let raw: str) + | ---- `Id`'s constructor declared private here +``` + +## every scope inside the class is the class's own code + +a method's local function and a nested class are written inside the body, so they construct it too. + +```by +class A: + private init() + + def clone(self): + def build(): + return A() + + return build() + + class Inner: + @staticmethod + def make(): + return A() + +reveal_type(A.Inner.make().clone()) # revealed: final A +``` + +## a subclass is outside a `private` constructor's class + +a subclass may not construct its base, and — because it inherits the private constructor — may not +be constructed itself. + +```by +class Base: + private init() + +class Derived(Base): ... + +base = Base() # error: [private-constructor] +derived = Derived() # error: [private-constructor] +``` + +## the boundary is the declaring class, not the declaring module + +another module may hold the class, name it, and pass it around. what it may not do is call it. + +`ids.by`: + +```by +class Id: + private init(let raw: str) + + @classmethod + def parse(cls, text: str) -> Id: + return Id(text.strip()) +``` + +`callers.by`: + +```by +from ids import Id + +parsed = Id.parse(" a ") +reveal_type(parsed.raw) # revealed: str + +made = Id("a") # error: [private-constructor] + +# naming the class does not launder it: the value is still the class itself +alias = Id +aliased = alias("a") # error: [private-constructor] +``` + +## a subclass reusing its base's name is still told whose constructor it is + +the two classes are told apart by identity rather than by name, so the message says the constructor +was inherited even where both classes are called `Id`. + +`base_id.by`: + +```by +class Id: + private init() +``` + +`shadowing.by`: + +```by +import base_id + +class Id(base_id.Id): ... + +# error: [private-constructor] "Cannot construct `Id`: it inherits `Id`'s private constructor" +made = Id() +``` + +## `type[A]` is not refused + +a `type[A]` may hold a subclass, and a subclass is free to declare a constructor of its own — the +same reason `type[SomeProtocol]` may be called where the protocol class itself may not. so the +guarantee a `private init` gives is over the class's own name, not over every route to a class +object. + +```by +class Id: + private init() + +def build(cls: type[Id]) -> Id: + return cls() +``` + +## declaring a constructor makes a subclass constructible again + +```by +class Base: + private init() + +class Derived(Base): + init() + +derived = Derived() +reveal_type(derived) # revealed: final Derived +``` + ## `private type` aliases bind the unmangled name the `_` prefix is applied by the lowering; in the type checker the alias binds the name as written. diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index e7e70f0c5e..3d153400f7 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -153,6 +153,8 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&SHADOWED_TYPE_VARIABLE); registry.register_lint(&SUBCLASS_OF_FINAL_CLASS); registry.register_lint(&SUBCLASS_OF_SEALED_CLASS); + registry.register_lint(&PRIVATE_CONSTRUCTOR); + registry.register_lint(&INEFFECTIVE_PRIVATE); registry.register_lint(&PRIVATE_IMPORT); registry.register_lint(&INVALID_EXTENSION); registry.register_lint(&AMBIGUOUS_EXTENSION_MEMBER); @@ -1114,6 +1116,71 @@ declare_lint! { } } +declare_lint! { + /// ## What it does + /// Checks for constructing a class whose `init` is declared `private`, from + /// outside that class's own body. + /// + /// ## Why is this bad? + /// A `private` constructor says the class decides how its instances are + /// made: callers go through a factory the class provides, which can pick a + /// subclass, return a cached instance, or reject the arguments. Calling the + /// constructor directly bypasses that. + /// + /// A subclass is outside the class's body too, so it cannot construct the + /// base either. + /// + /// ## Example + /// + /// ```by + /// class Id: + /// private init(let raw: str) + /// + /// @classmethod + /// def parse(cls, text: str) -> Id: + /// return Id(text.strip()) # ok: inside the class + /// + /// Id("x") # error: `Id`'s constructor is private + /// ``` + pub(crate) static PRIVATE_CONSTRUCTOR = { + summary: "detects construction of a class with a `private` constructor", + status: LintStatus::stable("0.0.79"), + default_level: Level::Error, + ty_compat: TyCompat::BasedPython, + } +} + +declare_lint! { + /// ## What it does + /// Checks for the `private` modifier on a class member whose name it cannot + /// hide. + /// + /// ## Why is this bad? + /// `private` hides a class member by renaming it so python's name-mangling + /// applies, and python mangles only a name with at most one trailing + /// underscore. A dunder is therefore left with the name it was written + /// with, and the modifier does nothing — which is worse than an error, + /// because the declaration reads as though the member were hidden. + /// + /// `init` is the exception. It is the one dunder `private` says something + /// about, and it is enforced at the construction site rather than by hiding + /// a name: see [`private-constructor`](private-constructor.md). + /// + /// ## Example + /// + /// ```by + /// class Point: + /// private def __repr__(self) -> str: # error: `private` does nothing here + /// return "Point()" + /// ``` + pub(crate) static INEFFECTIVE_PRIVATE = { + summary: "detects a `private` modifier on a name it cannot hide", + status: LintStatus::stable("0.0.79"), + default_level: Level::Error, + ty_compat: TyCompat::BasedPython, + } +} + declare_lint! { /// ## What it does /// Checks for imports of a symbol another module declared `private`. @@ -6054,6 +6121,48 @@ fn add_non_runtime_checkable_protocol_context<'db>( diagnostic.sub(class_def_diagnostic); } +/// basedpython: `A(...)` where `A`'s constructor is declared `private` and the +/// call is not inside `A`'s own body. +pub(crate) fn report_private_constructor<'db>( + context: &InferContext<'db, '_>, + call: &ast::ExprCall, + class: ClassType<'db>, + constructor: crate::types::visibility::PrivateConstructor<'db>, +) { + let Some(builder) = context.report_lint(&PRIVATE_CONSTRUCTOR, call) else { + return; + }; + let db = context.db(); + let class_name = class.name(db); + let owner_name = constructor.owner.name(db); + // naming the owner is only informative for a subclass, which is being + // refused over a constructor it did not declare. the classes are compared + // by identity, not by name: a subclass is free to reuse its base's name, + // and then the two messages would read the same while meaning different + // things + let inherited = class.class_literal(db).as_static() != Some(constructor.owner); + let mut diagnostic = if inherited { + builder.into_diagnostic(format_args!( + "Cannot construct `{class_name}`: it inherits `{owner_name}`'s private constructor" + )) + } else { + builder.into_diagnostic(format_args!( + "Cannot construct `{class_name}`: its constructor is private" + )) + }; + + let mut declaration = SubDiagnostic::new( + SubDiagnosticSeverity::Info, + format_args!("Only code inside `{owner_name}` may construct it"), + ); + declaration.annotate( + Annotation::secondary(constructor.function.spans(db).name).message(format_args!( + "`{owner_name}`'s constructor declared private here" + )), + ); + diagnostic.sub(declaration); +} + pub(crate) fn report_attempted_protocol_instantiation( context: &InferContext, call: &ast::ExprCall, diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index c0449927ab..5eb43fc610 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -83,7 +83,7 @@ use crate::types::diagnostic::{ INVALID_TYPE_VARIABLE_CONSTRAINTS, INVALID_TYPE_VARIABLE_DEFAULT, INVALID_VARIANCE_DECLARATION, NARROWING_GUARD_AS_VALUE, NON_EXHAUSTIVE_STATEMENT_EXPRESSION, NON_OVERLAPPING_CAST, NON_OVERLAPPING_TYPE_TEST, OPTIONAL_OBJECT_CONVERSION, POSSIBLY_MISSING_IMPLICIT_CALL, - POSSIBLY_MISSING_SUBMODULE, REFUTABLE_DESTRUCTURING, REFUTABLE_UNPACKING, + POSSIBLY_MISSING_SUBMODULE, PRIVATE_CONSTRUCTOR, REFUTABLE_DESTRUCTURING, REFUTABLE_UNPACKING, TRAILING_LAMBDA_PARAMETERS, TypeCheckDiagnostics, UNANNOTATED_MODEL_FIELD, UNAVAILABLE_IMPLICIT_SUPER_ARGUMENTS, UNDEFINED_REVEAL, UNRESOLVED_ATTRIBUTE, UNRESOLVED_GLOBAL, UNRESOLVED_REFERENCE, UNSOUND_ASSIGNMENT, UNSOUND_CAST, UNSOUND_YIELD, @@ -103,7 +103,7 @@ use crate::types::diagnostic::{ report_match_pattern_against_non_runtime_checkable_protocol, report_match_pattern_against_typed_dict, report_mismatched_type_name, report_possibly_missing_attribute, report_possibly_unresolved_reference, - report_too_many_positional_patterns_for_class_pattern, + report_private_constructor, report_too_many_positional_patterns_for_class_pattern, report_unplaceable_starred_class_pattern, report_unsound_assignment, report_unsound_yield, report_unsupported_augmented_assignment, report_unsupported_comparison, }; @@ -157,6 +157,7 @@ use crate::types::unpacker::{ UnpackResult, fixed_sequence_elements, sequence_from_literal_elements, tuple_literal_needs_promotion, }; +use crate::types::visibility::{private_constructor, scope_is_within_class}; use crate::types::{ BindingContext, BoundTypeVarInstance, CallDunderError, CallableBinding, CallableType, CallableTypes, ClassType, DeferredOperation, DeferredType, DynamicType, GeneratorTypeMode, @@ -12429,6 +12430,20 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { report_attempted_protocol_instantiation(&self.context, call_expression, protocol); } + // basedpython: a `private` constructor may only be called from + // inside the body of the class that declares it. `type[A]` is left + // alone for the same reason a protocol is: the class it stands for + // may be a subclass that declares a constructor of its own. + // the lint is asked first because answering the question at all + // costs a `__init__` lookup on every construction in the program + if !callable_type.is_subclass_of() + && self.context.is_lint_enabled(&PRIVATE_CONSTRUCTOR) + && let Some(constructor) = private_constructor(db, class) + && !scope_is_within_class(db, self.index, self.scope(), constructor.owner) + { + report_private_constructor(&self.context, call_expression, class, constructor); + } + // Inference of correctly-placed `TypeVar`, `ParamSpec`, `NewType`, and // `TypeAliasType` definitions is done in `infer_legacy_typevar`, // `infer_paramspec`, `infer_newtype_expression`, and diff --git a/crates/ty_python_semantic/src/types/infer/builder/function.rs b/crates/ty_python_semantic/src/types/infer/builder/function.rs index a3a73c7159..88e8cec86f 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/function.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/function.rs @@ -8,11 +8,11 @@ use crate::{ constraints::ConstraintSetBuilder, dedicated::pytest, diagnostic::{ - ABSTRACT_AND_FINAL_METHOD, FINAL_ON_NON_METHOD, INVALID_FIXTURE_TYPE, - INVALID_PARAMETER_DEFAULT, INVALID_PARAMETRIZE, INVALID_PARAMSPEC, INVALID_TYPE_FORM, - REDUNDANT_RETURN_ANNOTATION, REIFIED_CLASSMETHOD, TRAILING_LAMBDA_PARAMETERS, - TRAILING_LAMBDA_RETURN_TYPE, UNKNOWN_FIXTURE, UNSOUND_RETURN_STATEMENT, - USELESS_OVERLOAD_BODY, add_type_expression_reference_link, + ABSTRACT_AND_FINAL_METHOD, FINAL_ON_NON_METHOD, INEFFECTIVE_PRIVATE, + INVALID_FIXTURE_TYPE, INVALID_PARAMETER_DEFAULT, INVALID_PARAMETRIZE, + INVALID_PARAMSPEC, INVALID_TYPE_FORM, REDUNDANT_RETURN_ANNOTATION, REIFIED_CLASSMETHOD, + TRAILING_LAMBDA_PARAMETERS, TRAILING_LAMBDA_RETURN_TYPE, UNKNOWN_FIXTURE, + UNSOUND_RETURN_STATEMENT, USELESS_OVERLOAD_BODY, add_type_expression_reference_link, is_invalid_typed_dict_literal, report_bool_as_int, report_implicit_return_type, report_invalid_generator_function_return_type, report_invalid_return_type, report_shadowed_type_variable, report_unsound_return_statement, @@ -856,6 +856,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let mut function_decorators = FunctionDecorators::empty(); let mut dataclass_transformer_params = None; let mut final_decorator = None; + let mut private_modifier = None; for decorator in decorator_list { // basedpython: a trailing lambda block's synthetic decorator holds the @@ -888,6 +889,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { { if n.id.as_str() == "private" { function_decorators |= FunctionDecorators::PRIVATE; + private_modifier = Some(decorator); } continue; } @@ -976,6 +978,32 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { diagnostic.info("`@final` is only meaningful on methods and classes"); } + // basedpython: a `private` the lowering cannot act on hides nothing. it is + // reported here rather than left to the lowering, which can only silently + // do nothing with it + if let Some(private_modifier) = private_modifier + && !ruff_python_stdlib::basedpython::private_mangles(&name.id) + && name.id != "__init__" + && self + .index + .scope(self.scope().file_scope_id(db)) + .kind() + .is_class() + && let Some(builder) = self + .context + .report_lint(&INEFFECTIVE_PRIVATE, private_modifier) + { + let mut diagnostic = builder.into_diagnostic(format_args!( + "`private` has no effect on `{name}`", + name = name.id + )); + diagnostic.info( + "`private` renames a member to `__` so python's name-mangling hides it, \ + and python mangles only a name with at most one trailing underscore", + ); + diagnostic.info("`init` is the one dunder `private` says something about"); + } + // basedpython: a classmethod cannot have reified type parameters — the // classmethod binding hides the function whose closure would hold the // reified cells, so the specialization step has nothing to rebuild diff --git a/crates/ty_python_semantic/src/types/visibility.rs b/crates/ty_python_semantic/src/types/visibility.rs index ba0a4182e1..dbe57663ec 100644 --- a/crates/ty_python_semantic/src/types/visibility.rs +++ b/crates/ty_python_semantic/src/types/visibility.rs @@ -11,7 +11,16 @@ //! is name-mangled rather than renamed, and is unreachable through an import //! anyway. //! +//! A dunder is the exception, and the rest of this module is about it. Python +//! mangles only a name with at most one trailing underscore, so a `private` +//! dunder keeps the name it was written with. For every dunder but one that +//! makes `private` a no-op, which the parser reports. The one it does not is +//! `__init__`: [`private_constructor`] answers which class declared a private +//! one, so that construction can be refused wherever the declaring class's own +//! body does not reach — see [`PRIVATE_CONSTRUCTOR`]. +//! //! [`PRIVATE_IMPORT`]: super::diagnostic::PRIVATE_IMPORT +//! [`PRIVATE_CONSTRUCTOR`]: super::diagnostic::PRIVATE_CONSTRUCTOR use ruff_db::files::File; use ruff_db::parsed::parsed_module; @@ -82,6 +91,77 @@ pub(crate) fn private_method_name<'db>( Some(mangled_private_name(class.name(db).as_str(), member)) } +/// basedpython: the class that declares `class`'s `private` constructor — the +/// only class whose body may construct it. `None` when the constructor is not +/// private. +/// +/// The declaring class answers rather than `class` itself, so a subclass that +/// inherits a private `__init__` is reported at its own construction sites: the +/// constructor is the base's implementation detail, and a subclass is outside +/// the base's body like any other caller. +pub(crate) fn private_constructor<'db>( + db: &'db dyn Db, + class: crate::types::ClassType<'db>, +) -> Option> { + // the specialization says nothing about which `__init__` is found or how it + // was declared, so the question is asked of the class itself and the answer + // is shared by every specialization of it + *private_constructor_of(db, class.class_literal(db).as_static()?) +} + +/// Tracked for two reasons. It is asked at every construction site in the +/// program, and answering it walks an MRO. More importantly, the answer is read +/// off the *declaring* module — its `__init__`, and the semantic index that +/// says which class encloses it — so without a query boundary here, checking +/// one module would depend on the index of every module it constructs +/// something from. +#[salsa::tracked(returns(ref), heap_size = ruff_memory_usage::heap_size)] +fn private_constructor_of<'db>( + db: &'db dyn Db, + class: super::class::StaticClassLiteral<'db>, +) -> Option> { + let env = &crate::types::ProgramEnvironment::from_file(class.program_file(db)); + let init = class + .identity_specialization(db) + .class_member(db, env, "__init__", super::MemberLookupPolicy::default()) + .place + .ignore_possibly_undefined()?; + let function = declared_function(db, init)?; + if !function.has_known_decorator(db, super::function::FunctionDecorators::PRIVATE) { + return None; + } + let scope = function.definition(db).scope(db); + let index = crate::semantic_index(db, scope.program_file(db)); + let owner = super::infer::nearest_enclosing_class(db, index, scope)?; + Some(PrivateConstructor { owner, function }) +} + +/// A `private` constructor, and the class that declares it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) struct PrivateConstructor<'db> { + pub(crate) owner: super::class::StaticClassLiteral<'db>, + pub(crate) function: super::function::FunctionType<'db>, +} + +/// Whether `scope` lies within the body of `class` — the test for "this code is +/// the class's own", which a nested function or a nested class passes too. +pub(crate) fn scope_is_within_class<'db>( + db: &'db dyn Db, + index: &ty_python_core::SemanticIndex<'db>, + scope: ty_python_core::scope::ScopeId<'db>, + class: super::class::StaticClassLiteral<'db>, +) -> bool { + index + .ancestor_scopes(scope.file_scope_id(db)) + .filter_map(|(_, ancestor)| ancestor.node().as_class()) + .any(|ancestor| { + let definition = index.expect_single_definition(ancestor); + super::infer::original_class_type(db, definition) + .and_then(super::class::ClassLiteral::as_static) + == Some(class) + }) +} + /// The function a member's type stands for — the member itself, or the getter /// of the property wrapping it. fn declared_function<'db>( diff --git a/docs/basedpython/features/init-method.md b/docs/basedpython/features/init-method.md index c22eee7f07..cdcd67cb6c 100644 --- a/docs/basedpython/features/init-method.md +++ b/docs/basedpython/features/init-method.md @@ -124,6 +124,62 @@ a visibility modifier without `let` / `var` has no attribute to name, and any other modifier keyword (`final`, `abstract`, …) is meaningless in this position — both are reported as errors +## modifiers + +a modifier chain in front of `init` applies to the constructor exactly as it +would to the `def __init__` it stands for: + +```by +class A: + final init(let a: int) +``` + +```python +from typing import final + +class A: + @final + def __init__(self, a: int): + self.a: int = a +``` + +`static` and the `class def` classmethod modifier say which kind of function a +`def` is, and a constructor is neither, so they are rejected + +## private constructors + +`private init` means the class decides how its instances are made: its own body +may construct it, and nothing else may. that is what a factory method rests on + +```by +class Id: + private init(let raw: str) + + @classmethod + def parse(cls, text: str) -> Id: + return Id(text.strip()) + +Id("x") # rejected +``` + +a subclass is outside the base's body like any other caller, so it may neither +construct the base nor — while it inherits the private constructor — be +constructed itself. declaring an `init` of its own makes it constructible again + +the guarantee is over the class's own name. a `type[Id]` may hold a subclass, +and a subclass is free to declare a constructor of its own, so calling one is not +refused + +```by +def build(cls: type[Id]) -> Id: + return cls() # allowed +``` + +unlike an ordinary `private` method, the emitted `__init__` is not renamed: +python calls a constructor by its exact name, so there is no spelling that would +hide it and leave the class constructible. a private constructor is a static +guarantee rather than a runtime one + ## implicit `self` `self` may be omitted from the parameter list. it is implied, so it is diff --git a/docs/basedpython/features/modifiers.md b/docs/basedpython/features/modifiers.md index edd951f930..bb1d47cff9 100644 --- a/docs/basedpython/features/modifiers.md +++ b/docs/basedpython/features/modifiers.md @@ -211,6 +211,13 @@ def _helper(): ... bare `self.__helper` would name a different attribute in a subclass's body and none at all outside a class; the full spelling reaches the method from all of them +- `private` on a name python looks up verbatim — a dunder, or `_` — is reported + as having no effect. mangling applies only to a name with at most one + trailing underscore, so renaming would change what the member *is* rather + than who can reach it, and leaving it alone would make the modifier do + nothing. the one dunder where `private` says something is + [`init`](init-method.md#private-constructors), which is checked at the + construction site instead ## inlay hints diff --git a/ty.schema.json b/ty.schema.json index 1544a9d345..1c6114c157 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -1060,6 +1060,16 @@ } ] }, + "ineffective-private": { + "title": "detects a `private` modifier on a name it cannot hide", + "description": "## What it does\nChecks for the `private` modifier on a class member whose name it cannot\nhide.\n\n## Why is this bad?\n`private` hides a class member by renaming it so python's name-mangling\napplies, and python mangles only a name with at most one trailing\nunderscore. A dunder is therefore left with the name it was written\nwith, and the modifier does nothing — which is worse than an error,\nbecause the declaration reads as though the member were hidden.\n\n`init` is the exception. It is the one dunder `private` says something\nabout, and it is enforced at the construction site rather than by hiding\na name: see [`private-constructor`](private-constructor.md).\n\n## Example\n\n```by\nclass Point:\n private def __repr__(self) -> str: # error: `private` does nothing here\n return \"Point()\"\n```", + "default": "error", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "instance-layout-conflict": { "title": "detects class definitions that raise `TypeError` due to instance layout conflict", "description": "## What it does\n\nChecks for classes definitions which will fail at runtime due to \"instance memory layout conflicts\".\n\nThis error is usually caused by attempting to combine multiple classes that define non-empty\n`__slots__` in a class's [Method Resolution Order][method-resolution-order] (MRO), or by attempting\nto combine multiple builtin classes in a class's MRO.\n\n## Why is this bad?\n\nInheriting from bases with conflicting instance memory layouts will lead to a `TypeError` at\nruntime.\n\nAn instance memory layout conflict occurs when CPython cannot determine the memory layout instances\nof a class should have, because the instance memory layout of one of its bases conflicts with the\ninstance memory layout of one or more of its other bases.\n\nFor example, if a Python class defines non-empty `__slots__`, this will impact the memory layout of\ninstances of that class. Multiple inheritance from more than one different class defining non-empty\n`__slots__` is not allowed:\n\n```python\nclass A:\n __slots__ = (\"a\", \"b\")\n\n\nclass B:\n __slots__ = (\"a\", \"b\") # Even if the values are the same\n\n\n# TypeError: multiple bases have instance lay-out conflict\nclass C(A, B): ... # error\n```\n\nAn instance layout conflict can also be caused by attempting to use multiple inheritance with two\nbuiltin classes, due to the way that these classes are implemented in a CPython C extension:\n\n```python\n# TypeError: multiple bases have instance lay-out conflict\nclass A(int, float): ... # error\n```\n\nNote that pure-Python classes with no `__slots__`, or pure-Python classes with empty `__slots__`,\nare always compatible:\n\n```python\nclass A: ...\n\n\nclass B:\n __slots__ = ()\n\n\nclass C:\n __slots__ = (\"a\", \"b\")\n\n\n# fine\nclass D(A, B, C): ...\n```\n\n## Known problems\n\nClasses whose `__slots__` values cannot be determined statically are not always considered disjoint\nbases by ty. Static definitions can include string literals, fixed-length tuples, and literal lists,\nsets, or dictionaries of string literals.\n\nAdditionally, this check is not exhaustive: many C extensions (including several in the standard\nlibrary) define classes that use extended memory layouts and thus cannot coexist in a single MRO.\nSince it is currently not possible to represent this fact in stub files, having a full knowledge of\nthese classes is also impossible. When it comes to classes that do not define `__slots__` at the\nPython level, therefore, ty, currently only hard-codes a number of cases where it knows that a class\nwill produce instances with an atypical memory layout.\n\n## Further reading\n\n- [CPython documentation: `__slots__`](https://docs.python.org/3/reference/datamodel.html#slots)\n- [CPython documentation: Method Resolution Order](https://docs.python.org/3/glossary.html#term-method-resolution-order)\n\n[method-resolution-order]: https://docs.python.org/3/glossary.html#term-method-resolution-order", @@ -2070,6 +2080,16 @@ } ] }, + "private-constructor": { + "title": "detects construction of a class with a `private` constructor", + "description": "## What it does\nChecks for constructing a class whose `init` is declared `private`, from\noutside that class's own body.\n\n## Why is this bad?\nA `private` constructor says the class decides how its instances are\nmade: callers go through a factory the class provides, which can pick a\nsubclass, return a cached instance, or reject the arguments. Calling the\nconstructor directly bypasses that.\n\nA subclass is outside the class's body too, so it cannot construct the\nbase either.\n\n## Example\n\n```by\nclass Id:\n private init(let raw: str)\n\n @classmethod\n def parse(cls, text: str) -> Id:\n return Id(text.strip()) # ok: inside the class\n\nId(\"x\") # error: `Id`'s constructor is private\n```", + "default": "error", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "private-import": { "title": "detects imports of another module's `private` symbols", "description": "## What it does\nChecks for imports of a symbol another module declared `private`.\n\n## Why is this bad?\nA `private` declaration is part of its module's implementation, not its\ninterface. It is renamed with a leading underscore by the lowering, so an\nimporting module is reaching past a boundary the author drew explicitly,\nand the symbol may be renamed or removed without notice.\n\n## Example\n\n```by\n# helpers.by\nprivate type Key = str | int\n\n# main.by\nfrom helpers import Key # error: `Key` is private to `helpers`\n```", From 60a7a744770a5e5703047924f4b14e306ba9f0bc Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:55:17 +1000 Subject: [PATCH 03/11] fix generic construction: fluid widening in .by files, and Never for an unconstrained class type parameter Co-Authored-By: Claude Opus 5 --- .../basedpython_fluid_specializations.md | 37 ++++ .../basedpython_precise_unsolved_typevars.md | 113 ++++++++++++ .../resources/mdtest/bidirectional.md | 5 +- .../resources/mdtest/cycle/basic.md | 4 +- .../mdtest/generics/legacy/classes.md | 13 +- .../mdtest/generics/legacy/typevartuple.md | 9 +- .../mdtest/generics/legacy/variables.md | 4 +- .../mdtest/generics/pep695/classes.md | 6 +- .../mdtest/generics/pep695/typevartuple.md | 9 +- .../mdtest/generics/pep695/variables.md | 2 +- .../ty_python_semantic/src/types/call/bind.rs | 167 +++++++++++++++--- .../src/types/infer/builder.rs | 3 +- .../src/types/infer/builder/fluid.rs | 105 +++++++++-- .../src/types/infer/builder/subscript.rs | 2 +- .../ty_python_semantic/src/types/typevar.rs | 13 ++ .../features/precise-unsolved-typevars.md | 32 ++++ 16 files changed, 466 insertions(+), 58 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_fluid_specializations.md b/crates/ty_python_semantic/resources/mdtest/basedpython_fluid_specializations.md index ba8d60521e..5404c49262 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_fluid_specializations.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_fluid_specializations.md @@ -94,6 +94,43 @@ reveal_type(a) # revealed: A[object] reveal_type(a.x()) # revealed: object ``` +## constructor calls in basedpython files + +in a basedpython file a constructor call is inferred `final A`: the value it builds has *exactly* +the class named, never a subclass. that says nothing about the class's type arguments, so the +binding is as fluid as it is in a python file, and every widened view of it is just as exact + +```by +class A[in out T]: + def add(self, t: T): ... + +a = A() +reveal_type(a) # revealed: final A[Never] + +a.add(1) +reveal_type(a) # revealed: final A[int] + +# the declared type of a later assignment is adopted, exactly as it is for a collection literal +b: A[int | str] = a +reveal_type(a) # revealed: final A[int | str] +``` + +a container built by calling its class widens the same way a display of it does + +```by +s = set() +s2: set[int | str] = s +reveal_type(s) # revealed: final set[int | str] + +d = dict() +d2: dict[str, int] = d +reveal_type(d) # revealed: final dict[str, int] + +e = [] +e2: list[int | str] = e +reveal_type(e) # revealed: list[int | str] +``` + ## contravariant method use widens ```py diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_precise_unsolved_typevars.md b/crates/ty_python_semantic/resources/mdtest/basedpython_precise_unsolved_typevars.md index 38d1e09fe8..7a6ecc700e 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_precise_unsolved_typevars.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_precise_unsolved_typevars.md @@ -69,6 +69,119 @@ class Defaulted[T = str]: reveal_type(Defaulted()) # revealed: Defaulted[str] ``` +## a declared variance does not change the constructor answer + +```toml +[environment] +python-version = "3.13" +``` + +declaring `in out` pins the subtyping relation between a class's specializations. it says nothing +about what an instance built with no arguments holds, which is nothing, so the type parameter is +still left unsolved as `Never` — the same answer `[]` gets, for the same reason + +```by +class Cell[in out T]: + def __init__(self, *values: T): + self.values = values + + def add(self, value: T): + self.values += (value,) + +reveal_type(Cell()) # revealed: final Cell[Never] +reveal_type(Cell(1)) # revealed: final Cell[int] +``` + +a built-in container built by calling its class answers the same way + +```by +reveal_type(set()) # revealed: final set[Never] +reveal_type(list()) # revealed: final list[Never] +``` + +## a type variable an argument reached stays gradual + +```toml +[environment] +python-version = "3.13" +``` + +`Never` says the call built something with nothing in it, which is only true of a type parameter no +argument could have constrained. Where an argument did reach one and the solve still came back +empty, inference gave up rather than the value being empty, and `Never` would move the resulting +error away from the call that could not infer it. + +it is the parameter an argument was matched to that decides this, not what the solve produced. `V` +is reached and `K` is not, so a call that fills `value` still leaves `K` uninhabited: + +```py +class Keyed[K, V]: + def __init__(self, value: V): ... + +reveal_type(Keyed(1)) # revealed: Keyed[Never, Literal[1]] +``` + +`map`'s constructor reaches its element type through the callback. the solve over an overloaded +callback and a gradual iterable does not converge, and the fallback stays gradual: + +```py +import operator +from typing import Any + +ints: list[int] = [] +dynamic: Any = [] + +reveal_type(map(operator.add, ints, dynamic)) # revealed: map[Unknown] +``` + +## a gradual parameter reaches everything + +```toml +[environment] +python-version = "3.13" +``` + +a parameter annotated `Any` swallows its argument and says nothing about where the argument went, so +a call that filled one has not established that anything is empty. + +this is what `dict` and every subclass of it inherit: `__new__(cls, /, *args: Any, **kwargs: Any)` +takes the constructor's arguments before `__init__` does. reading that as "no argument reached the +value type" would make every `defaultdict(list)` hold `Never`, and its values unusable. + +```py +from collections import defaultdict + +# the key type really is unreached, and `Never` is the right answer for it. the value type is not: +# `default_factory` names it, and the solve simply did not resolve it +reveal_type(defaultdict(list)) # revealed: defaultdict[Never, Unknown] + +d = defaultdict(list) +reveal_type(d["key"]) # revealed: Unknown +``` + +a class of our own with the same catch-all `__new__` answers the same way, and one without it is +unaffected either way: + +```py +from typing import Any, Callable + +class Caught[K, V]: + values: list[V] + + def __new__(cls, /, *args: Any, **kwargs: Any) -> "Caught[K, V]": + raise NotImplementedError + + def __init__(self, make: Callable[[], V] | None, /) -> None: ... + +class Plain[K, V]: + values: list[V] + + def __init__(self, make: Callable[[], V] | None, /) -> None: ... + +reveal_type(Caught(list)) # revealed: Caught[Never, Unknown] +reveal_type(Plain(list)) # revealed: Plain[Never, Unknown] +``` + ## only where the type variable is an output ```toml diff --git a/crates/ty_python_semantic/resources/mdtest/bidirectional.md b/crates/ty_python_semantic/resources/mdtest/bidirectional.md index 1924655516..8cb0e67d1b 100644 --- a/crates/ty_python_semantic/resources/mdtest/bidirectional.md +++ b/crates/ty_python_semantic/resources/mdtest/bidirectional.md @@ -1327,8 +1327,9 @@ from collections import defaultdict from collections.abc import Mapping from typing import Any, Callable, overload -# the fork's covariant `Mapping` key places no lower bound on the key typevar -x1: Mapping[str, list[str]] = reveal_type(defaultdict(list)) # revealed: defaultdict[Unknown, list[str]] +# the fork's covariant `Mapping` key places no lower bound on the key typevar, and no argument +# reaches it, so it is left unsolved +x1: Mapping[str, list[str]] = reveal_type(defaultdict(list)) # revealed: defaultdict[Never, list[str]] x1["key"].append(1) # error: [invalid-argument-type] x2: Callable[[], list[str]] = reveal_type(list) # revealed: diff --git a/crates/ty_python_semantic/resources/mdtest/cycle/basic.md b/crates/ty_python_semantic/resources/mdtest/cycle/basic.md index 9555f76883..8140a3d525 100644 --- a/crates/ty_python_semantic/resources/mdtest/cycle/basic.md +++ b/crates/ty_python_semantic/resources/mdtest/cycle/basic.md @@ -643,10 +643,10 @@ from collections import defaultdict def tree(): return defaultdict(tree) -reveal_type(tree()) # revealed: defaultdict[Unknown, Divergent] +reveal_type(tree()) # revealed: defaultdict[Never, Divergent] nested = defaultdict(tree) -reveal_type(nested) # revealed: defaultdict[Unknown, defaultdict[Unknown, Divergent]] +reveal_type(nested) # revealed: defaultdict[Never, defaultdict[Never, Divergent]] ``` ## a recursive return value carried in a tuple element diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md index 5fbdf147ef..9ce11df13f 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/classes.md @@ -575,10 +575,12 @@ class D(Generic[DefaultT]): ... reveal_type(D()) # revealed: D[int] ``` -If a typevar does not provide a default, we use `Unknown`: +A typevar with no default and nothing to infer from is left unsolved. Under the fork's +`precise-unsolved-typevars` that is `Never` — the instance holds nothing — rather than python's +gradual `Unknown`: ```py -reveal_type(C()) # revealed: C[Unknown] +reveal_type(C()) # revealed: C[Never] ``` ## Inferring generic class parameters from constructors @@ -950,7 +952,8 @@ reveal_type(generic_context(into_regular_callable(C))) reveal_type(C("string")) # revealed: C[str] reveal_type(C(b"bytes")) # revealed: C[bytes] -reveal_type(C(12)) # revealed: C[Unknown] +# the matched overload's `x: int` parameter says nothing about `T`, so it is left unsolved +reveal_type(C(12)) # revealed: C[Never] C[str]("string") C[str](b"bytes") # error: [no-matching-overload] @@ -1022,7 +1025,7 @@ reveal_type(generic_context(C)) # revealed: ty_extensions._internal.GenericContext[T@C, U@C] reveal_type(generic_context(into_regular_callable(C))) -reveal_type(C()) # revealed: C[Unknown, Unknown] +reveal_type(C()) # revealed: C[Never, Never] class D(Generic[T, U]): def __init__(self) -> None: ... @@ -1032,7 +1035,7 @@ reveal_type(generic_context(D)) # revealed: ty_extensions._internal.GenericContext[T@D, U@D] reveal_type(generic_context(into_regular_callable(D))) -reveal_type(D()) # revealed: D[Unknown, Unknown] +reveal_type(D()) # revealed: D[Never, Never] ``` ## Generic subclass diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/typevartuple.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/typevartuple.md index bfe26fd946..2e13f0c5ab 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/typevartuple.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/typevartuple.md @@ -475,7 +475,7 @@ reveal_type(Prefix[int, bool, str]().attr) # revealed: tuple[int, bool, str] reveal_type(Prefix[int, *tuple[bool, str]]().attr) # revealed: tuple[int, bool, str] # TODO: Should this raise an error? -reveal_type(Prefix().attr) # revealed: tuple[Unknown, *tuple[Unknown, ...]] +reveal_type(Prefix().attr) # revealed: tuple[Never, *tuple[Unknown, ...]] ``` ```py @@ -488,7 +488,7 @@ reveal_type(Suffix[int, str, bool]().attr) # revealed: tuple[int, str, bool] reveal_type(Suffix[*tuple[int, str], bool]().attr) # revealed: tuple[int, str, bool] # TODO: Should this raise an error? -reveal_type(Suffix().attr) # revealed: tuple[*tuple[Unknown, ...], Unknown] +reveal_type(Suffix().attr) # revealed: tuple[*tuple[Unknown, ...], Never] ``` ```py @@ -500,7 +500,10 @@ reveal_type(Between[int, bool, str]().attr) # revealed: tuple[int, bool, str] reveal_type(Between[int, bool, bytes, str]().attr) # revealed: tuple[int, bool, bytes, str] reveal_type(Between[int, *tuple[bool], str]().attr) # revealed: tuple[int, bool, str] -reveal_type(Between().attr) # revealed: tuple[Unknown, *tuple[Unknown, ...], Unknown] +# the fixed elements are left unsolved, and the fork solves those to `Never`. the variadic +# middle is a `TypeVarTuple`, whose specialization is tuple-shaped rather than a plain type, +# so `Never` is not a value it can take and it stays gradual +reveal_type(Between().attr) # revealed: tuple[Never, *tuple[Unknown, ...], Never] # error: [invalid-type-arguments] "No type argument provided for required type variable `U` of class `Between`" reveal_type(Between[int]().attr) # revealed: tuple[Unknown, *tuple[Unknown, ...], Unknown] ``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/legacy/variables.md b/crates/ty_python_semantic/resources/mdtest/generics/legacy/variables.md index 961ce52893..60151b41a9 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/legacy/variables.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/legacy/variables.md @@ -393,7 +393,7 @@ V = TypeVar("V", default=Union[T, U]) class Valid(Generic[T, U, V]): ... -reveal_type(Valid()) # revealed: Valid[Unknown, Unknown, Unknown] +reveal_type(Valid()) # revealed: Valid[Never, Never, Never] reveal_type(Valid[int]()) # revealed: Valid[int, int, int] reveal_type(Valid[int, str]()) # revealed: Valid[int, str, int | str] reveal_type(Valid[int, str, None]()) # revealed: Valid[int, str, None] @@ -1108,7 +1108,7 @@ V = TypeVar("V", default="V") class D(Generic[V]): x: V -reveal_type(D().x) # revealed: Unknown +reveal_type(D().x) # revealed: Never ``` ## Regression diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md index f04da82a35..cc8ce7bbf2 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md @@ -301,10 +301,12 @@ class D[T = int]: ... reveal_type(D()) # revealed: D[int] ``` -If a typevar does not provide a default, we use `Unknown`: +A typevar with no default and nothing to infer from is left unsolved. Under the fork's +`precise-unsolved-typevars` that is `Never` — the instance holds nothing — rather than python's +gradual `Unknown`: ```py -reveal_type(C()) # revealed: C[Unknown] +reveal_type(C()) # revealed: C[Never] ``` ## Calls within the generic class diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/typevartuple.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/typevartuple.md index be4dbee134..916ba6b41c 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/typevartuple.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/typevartuple.md @@ -69,7 +69,7 @@ reveal_type(Prefix[int, bool, str]().attr) # revealed: tuple[int, bool, str] reveal_type(Prefix[int, *tuple[bool, str]]().attr) # revealed: tuple[int, bool, str] # TODO: Should this raise an error? -reveal_type(Prefix().attr) # revealed: tuple[Unknown, *tuple[Unknown, ...]] +reveal_type(Prefix().attr) # revealed: tuple[Never, *tuple[Unknown, ...]] ``` ```py @@ -82,7 +82,7 @@ reveal_type(Suffix[int, str, bool]().attr) # revealed: tuple[int, str, bool] reveal_type(Suffix[*tuple[int, str], bool]().attr) # revealed: tuple[int, str, bool] # TODO: Should this raise an error? -reveal_type(Suffix().attr) # revealed: tuple[*tuple[Unknown, ...], Unknown] +reveal_type(Suffix().attr) # revealed: tuple[*tuple[Unknown, ...], Never] ``` ```py @@ -94,7 +94,10 @@ reveal_type(Between[int, bool, str]().attr) # revealed: tuple[int, bool, str] reveal_type(Between[int, bool, bytes, str]().attr) # revealed: tuple[int, bool, bytes, str] reveal_type(Between[int, *tuple[bool], str]().attr) # revealed: tuple[int, bool, str] -reveal_type(Between().attr) # revealed: tuple[Unknown, *tuple[Unknown, ...], Unknown] +# the fixed elements are left unsolved, and the fork solves those to `Never`. the variadic +# middle is a `TypeVarTuple`, whose specialization is tuple-shaped rather than a plain type, +# so `Never` is not a value it can take and it stays gradual +reveal_type(Between().attr) # revealed: tuple[Never, *tuple[Unknown, ...], Never] # error: [invalid-type-arguments] "No type argument provided for required type variable `U` of class `Between`" reveal_type(Between[int]().attr) # revealed: tuple[Unknown, *tuple[Unknown, ...], Unknown] ``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md index 1c9adcc2b1..21be4e274b 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md @@ -1188,7 +1188,7 @@ reveal_type(C[int]().y) # revealed: int class D[T = T]: x: T -reveal_type(D().x) # revealed: Unknown +reveal_type(D().x) # revealed: Never ``` ## basedpython: type mappings diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index a17c9a9bed..5b2a1947d1 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -6036,6 +6036,10 @@ struct ArgumentTypeChecker<'a, 'db> { inferable_typevars: TypeVarSet<'db>, inference: Option>, + /// basedpython: the type variables the call's explicit arguments could have constrained. + /// See [`typevars_reached_by_arguments`]. + typevars_reached_by_arguments: Box<[BoundTypeVarIdentity<'db>]>, + /// Argument indices for which specialization inference has already produced a sufficiently /// precise argument mismatch. We can then silence `check_argument_type` for those arguments to /// avoid duplicate diagnostics. @@ -6142,6 +6146,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { return_ty: Type<'db>, errors: &'a mut Vec>, is_partial_application: bool, + typevars_reached_by_arguments: Box<[BoundTypeVarIdentity<'db>]>, ) -> Self { Self { db, @@ -6159,6 +6164,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { is_partial_application, inferable_typevars: TypeVarSet::None, inference: None, + typevars_reached_by_arguments, constraint_set_errors: vec![false; arguments.len()], } } @@ -6391,8 +6397,15 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { fn merged_specialization(&self) -> Option> { let env = self.env; - self.inference - .map(|inference| call_specialization(self.db, env, self.signature, inference)) + self.inference.map(|inference| { + call_specialization( + self.db, + env, + self.signature, + inference, + &self.typevars_reached_by_arguments, + ) + }) } /// The call's specialization with a type variable the call left unsolved kept gradual. @@ -6708,7 +6721,13 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { choose, ), }; - let specialization = call_specialization(self.db, env, self.signature, inference); + let specialization = call_specialization( + self.db, + env, + self.signature, + inference, + &self.typevars_reached_by_arguments, + ); self.return_ty = self.return_ty.apply_specialization(db, specialization); self.inference = Some(inference); @@ -8019,6 +8038,81 @@ fn inferable_typevar_occurrences<'db>( visitor.count.get() } +/// basedpython: whether `signature`'s calls solve an unsolved type variable precisely. +/// +/// The module that declares the callable governs its calls; a synthesized signature declared by +/// no module follows the default. +fn precise_unsolved_typevars<'db>(db: &'db dyn Db, signature: &Signature<'db>) -> bool { + signature.definition().is_none_or(|definition| { + db.analysis_settings(definition.file(db)) + .precise_unsolved_typevars + }) +} + +/// basedpython: the type variables an argument of this call could have constrained — those +/// named by a parameter the call actually matched an argument to. +/// +/// A type variable outside this set was handed nothing to infer from. One inside it was, and +/// the solve still came back empty, which means inference gave up rather than that the value +/// genuinely holds nothing. +/// +/// A gradual parameter puts every type variable in the set: the argument went somewhere the +/// annotation does not describe, so nothing about it has been established. +fn typevars_reached_by_arguments<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + signature: &Signature<'db>, + arguments: &CallArguments<'_, 'db>, + argument_matches: &[MatchedArgument<'db>], +) -> Box<[BoundTypeVarIdentity<'db>]> { + // only a generic signature is ever specialized, and `infer_specialization` leaves the + // inference empty for any other, so nothing reads this set for a non-generic call. + // `call_specialization` is the sole reader, and it answers from the inference alone + // unless the precise solve is on, so there is nothing to collect for anyone else + let Some(generic_context) = signature.generic_context else { + return Box::from([]); + }; + if !precise_unsolved_typevars(db, signature) { + return Box::from([]); + } + let reached = std::cell::RefCell::new(FxHashSet::default()); + let parameters = signature.parameters(); + let explicit_matches = arguments + .iter() + .zip(argument_matches) + // a synthetic argument is not something the caller wrote: for a constructor it is the + // instance being initialised, and the parameter it fills is declared in terms of the + // very type variables being solved + .filter(|((argument, _), _)| !matches!(argument, Argument::Synthetic)) + .map(|(_, matched)| matched); + for matched_parameter in explicit_matches.flat_map(|matched| matched.parameters.iter()) { + let Some(parameter) = parameters.get(matched_parameter.index) else { + continue; + }; + let annotated_type = parameter.annotated_type(); + // a gradual parameter swallows the argument and says nothing about it. the + // catch-all `__new__(cls, /, *args: Any, **kwargs: Any)` that `dict` and its + // subclasses inherit is the one that matters: the arguments really did go + // somewhere, so concluding that the instance holds nothing is unfounded, and + // every type parameter counts as reached + if annotated_type.is_dynamic() { + return generic_context + .variables(db) + .map(|typevar| typevar.identity(db)) + .collect(); + } + any_over_type(db, env, annotated_type, false, |ty: Type<'db>| { + if let Type::TypeVar(typevar) = ty { + reached.borrow_mut().insert(typevar.identity(db)); + } + false + }); + } + // a boxed slice rather than the set: this rides along in a `Binding`, it is only ever + // scanned for membership, and it holds a handful of entries at most + reached.into_inner().into_iter().collect() +} + /// Project a call's type-variable inference into a specialization. /// /// basedpython: a type variable that the call left entirely unsolved is solved to `Never` — the @@ -8026,11 +8120,17 @@ fn inferable_typevar_occurrences<'db>( /// This mirrors what fluid specializations already do for an empty collection literal, and is /// disabled by `analysis.precise-unsolved-typevars`. /// -/// Variance decides where that is the right answer. Where the type variable is only ever read out -/// of the call's result, `Never` describes a value nobody can observe. Where it is also written or -/// passed back in — an invariant `list[T]`, a contravariant `(T) -> None` — the same substitution -/// would instead say that nothing can ever be put there, turning a failure to infer into an error -/// at every later use of the result, so those keep the gradual `Unknown`. +/// Variance decides where that is the right answer for a type variable bound to a *function*. +/// Where it is only ever read out of the call's result, `Never` describes a value nobody can +/// observe. Where it is also written or passed back in — an invariant `list[T]`, a contravariant +/// `(T) -> None` — the same substitution would instead say that nothing can ever be put there, +/// turning a failure to infer into an error at every later use of the result, so those keep the +/// gradual `Unknown`. +/// +/// A *class* type parameter is the specialization of the instance the call builds. Whatever the +/// class declared, an instance built without anything reaching that parameter holds nothing, so +/// `A()` is `A[Never]` for the same reason `[]` is `list[Never]`. That only applies to a parameter +/// no argument reached: see [`typevars_reached_by_arguments`]. /// /// Every consumer of a call's specialization must go through here, or the return type and the /// binding's reported specialization would disagree about the same call. The one exception is @@ -8040,14 +8140,9 @@ fn call_specialization<'db>( env: &ProgramEnvironment<'db>, signature: &Signature<'db>, inference: TypeVarInference<'db>, + typevars_reached_by_arguments: &[BoundTypeVarIdentity<'db>], ) -> Specialization<'db> { - // the module that declares the callable governs its calls; a synthesized signature declared by - // no module follows the default - let precise_unsolved_typevars = signature.definition().is_none_or(|definition| { - db.analysis_settings(definition.file(db)) - .precise_unsolved_typevars - }); - if !precise_unsolved_typevars { + if !precise_unsolved_typevars(db, signature) { return inference.merged_specialization(db); } @@ -8059,10 +8154,20 @@ fn call_specialization<'db>( || typevar.is_paramspec(db) || typevar.is_parameter_pack(db) || typevar.is_typevartuple(db) - || !matches!( - typevar.positional_variance(db, env), - TypeVarVariance::Covariant | TypeVarVariance::Bivariant - ) + // the variance rule is about *function* type parameters: `Never` in an input + // position says "nothing can ever go here", and the error that produces lands + // far from the call that failed to infer. a class type parameter no argument + // could have constrained is a different thing — it is the specialization of the + // instance the call just built, and that instance really does hold nothing yet, + // which is why `A()` is `A[Never]` for the same reason `[]` is `list[Never]`. + // one an argument *did* reach stays gradual: an empty solve there means + // inference gave up, not that the value is empty + || !((typevar.binds_class_specialization(db) + && !typevars_reached_by_arguments.contains(&typevar.identity(db))) + || matches!( + typevar.positional_variance(db, env), + TypeVarVariance::Covariant | TypeVarVariance::Bivariant + )) { None } else { @@ -8109,6 +8214,10 @@ pub(crate) struct Binding<'db> { /// The type-variable inference result for this binding, if the callable is generic. inference: Option>, + /// basedpython: the type variables the call's explicit arguments could have constrained. + /// See [`typevars_reached_by_arguments`]. + typevars_reached_by_arguments: Box<[BoundTypeVarIdentity<'db>]>, + /// Whether these arguments construct a partial instead of completing a call. is_partial_application: bool, @@ -8205,6 +8314,7 @@ impl<'db> Binding<'db> { inference: None, is_partial_application: false, argument_matches: Box::from([]), + typevars_reached_by_arguments: Box::from([]), variadic_argument_matched_to_variadic_parameter: false, parameter_tys: Box::from([]), errors: vec![], @@ -8748,6 +8858,13 @@ impl<'db> Binding<'db> { self.variadic_argument_matched_to_variadic_parameter = matcher.variadic_argument_matched_to_variadic_parameter; self.argument_matches = matcher.finish(db, env); + self.typevars_reached_by_arguments = typevars_reached_by_arguments( + db, + env, + &self.signature, + arguments, + &self.argument_matches, + ); } fn check_types( @@ -8789,6 +8906,7 @@ impl<'db> Binding<'db> { self.return_ty, &mut self.errors, self.is_partial_application, + self.typevars_reached_by_arguments.clone(), ); // If this overload is generic, first see if we can infer a specialization of the function @@ -9192,8 +9310,15 @@ impl<'db> Binding<'db> { db: &'db dyn Db, env: &ProgramEnvironment<'db>, ) -> Option> { - self.inference - .map(|inference| call_specialization(db, env, &self.signature, inference)) + self.inference.map(|inference| { + call_specialization( + db, + env, + &self.signature, + inference, + &self.typevars_reached_by_arguments, + ) + }) } pub(crate) fn errors(&self) -> &[BindingError<'db>] { diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 5eb43fc610..30b163c73d 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -12591,8 +12591,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let value_type = self.expression_type(value); if let Some(collection_def) = self.index.fluid_candidate_binding(value) - && let Some((collection_literal, _)) = - value_type.class_specialization(self.db(), env) + && let Some((collection_literal, _)) = self.fluid_class_specialization(value_type) { let identity_instance = Type::instance( self.db(), diff --git a/crates/ty_python_semantic/src/types/infer/builder/fluid.rs b/crates/ty_python_semantic/src/types/infer/builder/fluid.rs index ae5f9dfb97..e45565a7c4 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/fluid.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/fluid.rs @@ -36,6 +36,7 @@ use itertools::Itertools; use ruff_python_ast as ast; +use ruff_python_ast::helpers::TypeModifier; use rustc_hash::FxHashSet; use ty_python_core::Statement; @@ -52,7 +53,10 @@ use crate::types::constraints::ConstraintSetBuilder; use crate::types::generics::{GenericContext, SpecializationBuilder}; use crate::types::infer::{InferenceRegion, infer_expression_types, infer_statement_types}; use crate::types::infer_definition_types; -use crate::types::{KnownFunction, Type, TypeContext, TypeVarVariance}; +use crate::types::{ + KnownFunction, RestrictedType, Specialization, StaticClassLiteral, Type, TypeContext, + TypeVarVariance, +}; /// the constraining and locking events of a fluid candidate binding that can /// have executed before a given program point @@ -357,6 +361,60 @@ impl<'db> TypeInferenceBuilder<'db, '_> { .any(|declaration| declaration.declaration.definition().is_some()) } + /// basedpython: take the use-site modifier off a fluid candidate's type + /// + /// a constructor call is inferred `final A` (exact construction), and the whole + /// fluid machinery below asks what class an instance is and how it is + /// specialized — questions the wrapper answers with "none of the above". the + /// modifier is split off on the way in and put back on the way out, because + /// widening only changes the specialization: a value built by `A()` is still + /// exactly an `A` once its element type has widened + fn split_fluid_restriction(&self, ty: Type<'db>) -> (Option, Type<'db>) { + match ty { + Type::Restricted(restricted) => ( + Some(restricted.modifier(self.db())), + restricted.value_type(self.db()), + ), + _ => (None, ty), + } + } + + /// basedpython: the class and specialization of a type a fluid path is holding + /// + /// Every question the fluid machinery asks — which class is this, how is it + /// specialized — is about the value, and a use-site modifier says nothing + /// about either. In a basedpython file a constructor call is inferred + /// `final A`, and an annotation may be written `final A[int]`, so asking + /// [`Type::class_specialization`] directly would answer "not a class + /// instance" for exactly the values this module exists to widen. Ask through + /// here instead. + pub(super) fn fluid_class_specialization( + &self, + ty: Type<'db>, + ) -> Option<(StaticClassLiteral<'db>, Specialization<'db>)> { + ty.erase_restriction(self.db()) + .class_specialization(self.db(), self.program_environment()) + } + + /// put back the modifier [`Self::split_fluid_restriction`] took off, replacing + /// any modifier `ty` still carries rather than stacking a second one on it + fn restore_fluid_restriction( + &self, + modifier: Option, + ty: Type<'db>, + ) -> Type<'db> { + let Some(modifier) = modifier else { + return ty; + }; + let db = self.db(); + RestrictedType::from_type_expression( + db, + self.program_environment(), + modifier, + ty.erase_restriction(db), + ) + } + /// infer a constructor call that may be the assigned value of a fluid candidate: /// the inferred specialization retains literal types, and constraints from later /// uses of the binding are combined with it. only direct constructor calls @@ -381,8 +439,13 @@ impl<'db> TypeInferenceBuilder<'db, '_> { // fluid-specialization performance investigation. let ty = self.infer_call_expression_impl(call_expr, callable_type, tcx); + // the class and its generic context are read off the instance itself: in a + // basedpython file `ty` is `final A`, and the restriction is put back by + // `fluid_eventual_type`, which is handed the creation type as it stands + let instance = ty.erase_restriction(self.db()); + if let Some(fluid_def) = fluid_def - && let Some((class_literal, _)) = ty.class_specialization(self.db(), env) + && let Some((class_literal, _)) = self.fluid_class_specialization(instance) && let Some(generic_context) = class_literal.generic_context(self.db()) { let identity_instance = Type::instance( @@ -1058,6 +1121,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { ) -> Type<'db> { let env = self.program_environment(); self.fluid_creation = Some(creation); + let (restriction, creation) = self.split_fluid_restriction(creation); let timeline = self.build_fluid_timeline(candidate_def, identity_instance, generic_context, creation); @@ -1091,10 +1155,12 @@ impl<'db> TypeInferenceBuilder<'db, '_> { // for flow-sensitive uses (recorded above as `fluid_creation`). Its public // type — what untracked escapes such as multi-binding uses or other scopes // observe — must stay gradual, so present an empty collection as `Unknown`. - return self.promote_empty_specialization(identity_instance, generic_context, creation); + let promoted = + self.promote_empty_specialization(identity_instance, generic_context, creation); + return self.restore_fluid_restriction(restriction, promoted); } - match eventual { + let eventual = match eventual { Some(solution) => solution.unwrap_or(creation), None => self .solve_fluid_specialization( @@ -1104,7 +1170,8 @@ impl<'db> TypeInferenceBuilder<'db, '_> { promote, ) .unwrap_or(creation), - } + }; + self.restore_fluid_restriction(restriction, eventual) } /// If `creation` is an empty collection — every element typevar solved to `Never`, @@ -1117,9 +1184,8 @@ impl<'db> TypeInferenceBuilder<'db, '_> { generic_context: GenericContext<'db>, creation: Type<'db>, ) -> Type<'db> { - let env = self.program_environment(); let db = self.db(); - let Some((_, specialization)) = creation.class_specialization(db, env) else { + let Some((_, specialization)) = self.fluid_class_specialization(creation) else { return creation; }; if !specialization.types(db).iter().all(Type::is_never) { @@ -1217,8 +1283,9 @@ impl<'db> TypeInferenceBuilder<'db, '_> { let Some(creation) = creation else { return fallback; }; + let (restriction, creation) = self.split_fluid_restriction(creation); - let Some((class_literal, _)) = creation.class_specialization(db, env) else { + let Some((class_literal, _)) = self.fluid_class_specialization(creation) else { // The creation type contains a cycle-recovery placeholder; fall back // until the fixpoint converges. return fallback; @@ -1276,10 +1343,12 @@ impl<'db> TypeInferenceBuilder<'db, '_> { // looked like before it was solved. if tcx.prescribes_type_arguments() || (annotation.has_unspecialized_type_var(db, env) - && annotation.class_specialization(db, env).is_some()) + && self.fluid_class_specialization(annotation).is_some()) { if let (Some(timeline), Some(index)) = (timeline, snapshot) { - return timeline.solution(index, true).unwrap_or(fallback); + return timeline.solution(index, true).map_or(fallback, |solution| { + self.restore_fluid_restriction(restriction, solution) + }); } return self .solve_fluid_specialization( @@ -1288,7 +1357,9 @@ impl<'db> TypeInferenceBuilder<'db, '_> { creation_constraint.into_iter().chain(gathered.constraints), true, ) - .unwrap_or(fallback); + .map_or(fallback, |solution| { + self.restore_fluid_restriction(restriction, solution) + }); } if self.fluid_constraint_binds_typevars(identity_instance, generic_context, annotation) @@ -1300,7 +1371,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { } if gathered.is_creation() { - return creation; + return self.restore_fluid_restriction(restriction, creation); } // Literal types accumulate through widening events and are promoted once @@ -1308,7 +1379,11 @@ impl<'db> TypeInferenceBuilder<'db, '_> { // view instead. let promote = gathered.locked && gathered.promote_on_lock; if let (Some(timeline), Some(index)) = (timeline, snapshot) { - return timeline.solution(index, promote).unwrap_or(fallback); + return timeline + .solution(index, promote) + .map_or(fallback, |solution| { + self.restore_fluid_restriction(restriction, solution) + }); } self.solve_fluid_specialization( identity_instance, @@ -1316,6 +1391,8 @@ impl<'db> TypeInferenceBuilder<'db, '_> { creation_constraint.into_iter().chain(gathered.constraints), promote, ) - .unwrap_or(fallback) + .map_or(fallback, |solution| { + self.restore_fluid_restriction(restriction, solution) + }) } } diff --git a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs index 8b4f3867d1..3bac5f9259 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs @@ -2309,7 +2309,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { if is_valid_assignment && self.fluid_specializations_enabled() && let Some(collection_def) = self.index.fluid_candidate_binding(object) - && let Some((class_literal, _)) = object_ty.class_specialization(db, env) + && let Some((class_literal, _)) = self.fluid_class_specialization(object_ty) { let identity_instance = Type::instance(db, env, class_literal.identity_specialization(db)); diff --git a/crates/ty_python_semantic/src/types/typevar.rs b/crates/ty_python_semantic/src/types/typevar.rs index 8f56f0b54c..c52bd560ec 100644 --- a/crates/ty_python_semantic/src/types/typevar.rs +++ b/crates/ty_python_semantic/src/types/typevar.rs @@ -1773,6 +1773,19 @@ impl<'db> BoundTypeVarInstance<'db> { self.variance(db) } + /// basedpython: whether this type parameter belongs to a class rather than to a + /// function. + /// + /// A class type parameter left unsolved by a call is the specialization of the + /// instance that call builds, and nothing else: the class type parameters of a + /// method are already fixed by the receiver before the method is bound. + pub(crate) fn binds_class_specialization(self, db: &'db dyn Db) -> bool { + let BindingContext::Definition(definition) = self.binding_context(db) else { + return false; + }; + binding_type(db, definition).is_class_literal() + } + /// basedpython: whether this parameter is declared `in out` on a class whose /// body never writes through it. /// diff --git a/docs/basedpython/features/precise-unsolved-typevars.md b/docs/basedpython/features/precise-unsolved-typevars.md index 2202120fc7..5b68081464 100644 --- a/docs/basedpython/features/precise-unsolved-typevars.md +++ b/docs/basedpython/features/precise-unsolved-typevars.md @@ -57,6 +57,38 @@ variance is read positionally for a type variable bound to a function: python on variance meaning for a generic class, and a legacy `TypeVar("T")` is invariant under its own rules, so `def f[T]() -> T` and its legacy spelling say the same thing here +the variance rule is about type variables bound to a *function*. a class's own type parameter that +no argument could have reached is the specialization of an instance the call just built with nothing +in it, so it is `Never` whatever the class declared + +```by +class Cell[in out T]: + def __init__(self, *values: T): ... + def add(self, value: T): ... + +reveal_type(Cell()) # final Cell[Never] +``` + +that is the answer `[]` gets, and it is not a dead end for the same reason: +[fluid specializations](fluid-specializations.md) widen the binding at its first use + +*reached* is the point. a type parameter an argument did reach, and the solver still could not +resolve, stays gradual: an empty solve there means inference gave up, not that the value is empty, +and `Never` would move the error away from the call that could not infer it + +```python +reveal_type(map(operator.add, ints, dynamic)) # map[Unknown] — the callback did reach `T` +``` + +a gradual parameter reaches everything, because it says nothing about where the +argument went. that is what keeps `dict` and its subclasses usable: the +`__new__(cls, /, *args: Any, **kwargs: Any)` they inherit takes the +constructor's arguments before `__init__` does + +```python +reveal_type(defaultdict(list)) # defaultdict[Never, Unknown] +``` + ## the call still returns a return type of `Never` normally says the callee does not return, and a statement-level call to From 9c760b87f575e5217f2bd56e9a186cee5760119e Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:55:22 +1000 Subject: [PATCH 04/11] add BY023 for a tuple[...] annotation, and lower tuple type elements like every other type expression Co-Authored-By: Claude Opus 5 --- .../src/transforms/annotation.rs | 104 ++++-- .../src/transforms/literal_types.rs | 206 +----------- .../test/fixtures/basedpython/BY023.by | 64 ++++ .../test/fixtures/basedpython/BY023.py | 4 + .../src/checkers/ast/analyze/expression.rs | 3 + crates/ruff_linter/src/codes.rs | 1 + .../ruff_linter/src/rules/basedpython/mod.rs | 2 + .../rules/manual_tuple_annotation.rs | 189 +++++++++++ .../src/rules/basedpython/rules/mod.rs | 2 + ...s__basedpython__tests__BY023_BY023.by.snap | 314 ++++++++++++++++++ ...s__basedpython__tests__BY023_BY023.py.snap | 4 + crates/ruff_linter/src/settings/mod.rs | 1 + crates/ruff_python_semantic/src/model.rs | 14 + docs/basedpython/features/linter.md | 4 +- ruff.schema.json | 2 + 15 files changed, 676 insertions(+), 238 deletions(-) create mode 100644 crates/ruff_linter/resources/test/fixtures/basedpython/BY023.by create mode 100644 crates/ruff_linter/resources/test/fixtures/basedpython/BY023.py create mode 100644 crates/ruff_linter/src/rules/basedpython/rules/manual_tuple_annotation.rs create mode 100644 crates/ruff_linter/src/rules/basedpython/snapshots/ruff_linter__rules__basedpython__tests__BY023_BY023.by.snap create mode 100644 crates/ruff_linter/src/rules/basedpython/snapshots/ruff_linter__rules__basedpython__tests__BY023_BY023.py.snap diff --git a/crates/by_transforms/src/transforms/annotation.rs b/crates/by_transforms/src/transforms/annotation.rs index 517af050e7..e6c50a5fde 100644 --- a/crates/by_transforms/src/transforms/annotation.rs +++ b/crates/by_transforms/src/transforms/annotation.rs @@ -1,3 +1,5 @@ +use std::cell::{Cell, RefCell}; + use ruff_diagnostics::{Edit, Fix}; use ruff_python_ast::{Expr, PythonVersion, Stmt}; use ruff_text_size::Ranged; @@ -5,8 +7,9 @@ use ruff_text_size::Ranged; use crate::Config; use crate::config::FloatLiteralLowering; use crate::transforms::ast_driver::{PassContext, TypeAwarePass}; +use crate::transforms::callable::CallableSyntax; +use crate::transforms::optional_type; use crate::transforms::type_expr_walker::{Recurse, TypeExprVisitor, TypePos, walk_type_positions}; -use crate::transforms::{literal_types, optional_type}; use crate::type_info::TypeInfo; /// The element type an unpacked tuple element wraps, whichever way the target @@ -41,9 +44,13 @@ pub(crate) struct TupleLiteralType<'src> { source: &'src str, types: &'src dyn TypeInfo, min_version: PythonVersion, - float_literals: FloatLiteralLowering, /// set when a lowering spelled an `Unpack`, so the pass can ask for the import - needs_unpack_import: std::cell::Cell, + needs_unpack_import: Cell, + /// the shared type-expression lowerer, used for every element this + /// transform re-emits. our replacement covers the whole tuple, so the + /// dedicated passes' edits inside it are dropped — without this an element + /// would reach the output as surface syntax + leaves: RefCell>, edits: Vec, } @@ -58,8 +65,8 @@ impl<'src> TupleLiteralType<'src> { source, types, min_version, - float_literals, - needs_unpack_import: std::cell::Cell::new(false), + needs_unpack_import: Cell::new(false), + leaves: RefCell::new(CallableSyntax::new(source, float_literals).with_types(types)), edits: Vec::new(), } } @@ -81,10 +88,17 @@ impl<'src> TupleLiteralType<'src> { &self.source[usize::from(range.start())..usize::from(range.end())] } - /// Source text for `expr`, with literal-type rewrites applied if needed. + /// Python source for an element `transform_annotation` does not itself + /// rewrite, lowered through the shared type-expression lowerer. + /// + /// Taking the source verbatim here would leak basedpython surface syntax: + /// `float` has to become `JustFloat`, `dynamic` has to become `Any`, and an + /// intersection or a callable arrow has to be spelled the python way. The + /// dedicated passes cannot reach inside a tuple type, because the shared + /// walker does not descend into a parenthesized tuple, and our replacement + /// covers the whole expression anyway. fn fallback_src(&self, expr: &Expr) -> String { - literal_types::rewrite_type_expr(self.source, self.types, expr, self.float_literals) - .unwrap_or_else(|| self.src(expr.range()).to_owned()) + self.leaves.borrow_mut().lower_type_expr(expr) } /// Returns a rewritten annotation string if any transformation is needed, @@ -175,7 +189,7 @@ impl<'src> TupleLiteralType<'src> { .join(", "); let returns_str = self .transform_annotation(returns_expr) - .unwrap_or_else(|| self.src(returns_expr.range()).to_owned()); + .unwrap_or_else(|| self.fallback_src(returns_expr)); let value_str = self.src(s.value.range()); return Some(format!("{value_str}[[{params_str}], {returns_str}]")); } @@ -237,12 +251,12 @@ impl<'src> TupleLiteralType<'src> { return String::new(); } self.transform_annotation(&named.value) - .unwrap_or_else(|| self.src(named.value.range()).to_owned()) + .unwrap_or_else(|| self.fallback_src(&named.value)) } Expr::Starred(_) => String::new(), _ => self .transform_annotation(elt) - .unwrap_or_else(|| self.src(elt.range()).to_owned()), + .unwrap_or_else(|| self.fallback_src(elt)), } } @@ -272,16 +286,16 @@ impl<'src> TupleLiteralType<'src> { if let Expr::Starred(unpacked) = named.value.as_ref() { let inner_src = self .transform_annotation(&unpacked.value) - .unwrap_or_else(|| self.src(unpacked.value.range()).to_owned()); + .unwrap_or_else(|| self.fallback_src(&unpacked.value)); return self.unpack(&inner_src); } let value_src = self .transform_annotation(&named.value) - .unwrap_or_else(|| self.src(named.value.range()).to_owned()); + .unwrap_or_else(|| self.fallback_src(&named.value)); return self.unpack(&format!("tuple[{value_src}, ...]")); } self.transform_annotation(&named.value) - .unwrap_or_else(|| self.src(named.value.range()).to_owned()) + .unwrap_or_else(|| self.fallback_src(&named.value)) } // `*: T` (anonymous variadic) → `*tuple[T, ...]` // `**: T` (kwargs catch-all) → dropped @@ -291,7 +305,7 @@ impl<'src> TupleLiteralType<'src> { } let value_src = self .transform_annotation(&s.value) - .unwrap_or_else(|| self.src(s.value.range()).to_owned()); + .unwrap_or_else(|| self.fallback_src(&s.value)); if parameter_shape { self.unpack(&format!("tuple[{value_src}, ...]")) } else { @@ -304,7 +318,7 @@ impl<'src> TupleLiteralType<'src> { _ => self .transform_annotation(elt) .or_else(|| optional_type::rewrite_type_expr(self.source, elt, self.min_version)) - .unwrap_or_else(|| self.src(elt.range()).to_owned()), + .unwrap_or_else(|| self.fallback_src(elt)), } } } @@ -353,14 +367,10 @@ impl TypeAwarePass for TupleLiteralTypePass<'_> { self.config.float_literals, ); walk_type_positions(stmts, Some(types), &mut inner); - let mut wraps_literal = false; for fix in inner.edits { for edit in fix.edits() { let range = edit.range(); let repl = edit.content().unwrap_or_default().to_owned(); - if repl.contains("Literal[") { - wraps_literal = true; - } ctx.text_edits.push((range, repl)); } } @@ -368,13 +378,17 @@ impl TypeAwarePass for TupleLiteralTypePass<'_> { ctx.required_imports .push("from typing import Unpack".to_owned()); } - // when our embedded literal-type lowering produced `Literal[...]` text, - // request the import. the standalone literal_types pass doesn't see - // the bare literal anymore because we've replaced its parent annotation - if wraps_literal && !literal_types::literal_already_imported(types) { + // whatever the element lowerer spelled needs its own imports, and a + // callable arrow among the elements needs its hoisted `Protocol` class. + // our replacement covers the whole tuple, so the `callable` pass's own + // visit never reaches these and cannot ask for them + let mut leaves = inner.leaves.borrow_mut(); + let defs = leaves.class_defs().to_owned(); + if !defs.is_empty() { ctx.required_imports - .push("from typing import Literal".to_owned()); + .push(format!("{}\n", defs.trim_end_matches('\n'))); } + ctx.required_imports.extend(leaves.take_import_lines()); } } @@ -451,8 +465,34 @@ mod tests { #[test] fn nested_tuple() { check( - "a: (int, (str, float))\n", - "a: tuple[int, tuple[str, float]]\n", + "a: (int, (str, bytes))\n", + "a: tuple[int, tuple[str, bytes]]\n", + ); + } + + /// an element gets the same lowering any other type expression would. + /// the rewrite replaces the whole tuple, so the dedicated passes' edits + /// inside it are dropped and the element has to be lowered here instead — + /// otherwise `float` would keep python's `int | float` reading, `dynamic` + /// and an intersection would reach the output as basedpython syntax, and a + /// callable arrow would not be python at all + #[test] + fn an_element_is_lowered_like_any_other_type() { + check( + "a: (str, float)\n", + "from ty_extensions import JustFloat\na: tuple[str, JustFloat]\n", + ); + check( + "a: (dynamic, int)\n", + "from typing import Any\na: tuple[Any, int]\n", + ); + check( + "a: (A & B, int)\n", + "from ty_extensions import Intersection\na: tuple[Intersection[A, B], int]\n", + ); + check( + "a: (str, (int) -> bytes)\n", + "from typing import Callable\na: tuple[str, Callable[[int], bytes]]\n", ); } @@ -476,11 +516,11 @@ mod tests { #[test] fn subscript_non_parenthesized_tuple_propagated() { - // dict[str, (int, float)] — the `str, (int, float)` is an unparenthesized + // dict[str, (int, bytes)] — the `str, (int, bytes)` is an unparenthesized // tuple in the slice; only the parenthesized inner tuple should be rewritten check( - "a: dict[str, (int, float)]\n", - "a: dict[str, tuple[int, float]]\n", + "a: dict[str, (int, bytes)]\n", + "a: dict[str, tuple[int, bytes]]\n", ); } @@ -488,11 +528,11 @@ mod tests { fn function_parameter_annotation() { check( indoc! {" - def f(x: (int, str)) -> (bool, float): + def f(x: (int, str)) -> (bool, bytes): pass "}, indoc! {" - def f(x: tuple[int, str]) -> tuple[bool, float]: + def f(x: tuple[int, str]) -> tuple[bool, bytes]: pass "}, ); diff --git a/crates/by_transforms/src/transforms/literal_types.rs b/crates/by_transforms/src/transforms/literal_types.rs index f740305f32..e87a8d759f 100644 --- a/crates/by_transforms/src/transforms/literal_types.rs +++ b/crates/by_transforms/src/transforms/literal_types.rs @@ -15,7 +15,7 @@ //! resolves to a class, type alias, or imported/unknown name). use ruff_diagnostics::{Edit, Fix}; -use ruff_python_ast::{Expr, ExprSubscript, Operator, Stmt, UnaryOp}; +use ruff_python_ast::{Expr, Operator, Stmt, UnaryOp}; use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::config::FloatLiteralLowering; @@ -71,191 +71,6 @@ impl<'src> LiteralType<'src> { self.is_typing_name(value, "Literal") } - /// Transform a type expression. Returns `Some(rewrite)` if any literal - /// was promoted to `Literal[...]`, else `None`. - /// - /// `at_root` distinguishes a bare-annotation position from an interior - /// position. bare `None` is left alone in every position — `None` is the - /// idiomatic spelling for `NoneType` and a `Literal[None]` wrapper here is - /// noise that mutates the user's source form unnecessarily. union-arm - /// `None`s still join an adjacent literal group via the path in - /// `transform_union` - fn transform_type_expr(&mut self, expr: &Expr, _at_root: bool) -> Option { - if matches!(expr, Expr::NoneLiteral(_)) { - return None; - } - - if is_literal_expr(expr, self.float_literals) { - self.needs_literal_import = true; - return Some(format!("Literal[{}]", self.src(expr.range()))); - } - - if let Some(nominal) = nominal_float_type(expr, self.float_literals) { - return Some(nominal.to_owned()); - } - - if let Expr::BinOp(b) = expr { - if matches!(b.op, Operator::BitOr) { - return self.transform_union(expr); - } - } - - if let Expr::Subscript(s) = expr { - return self.transform_subscript(s); - } - - None - } - - fn transform_union(&mut self, expr: &Expr) -> Option { - enum Group { - Literals(Vec), - Other(String), - } - - let parts = flatten_union(expr); - let mut groups: Vec = Vec::new(); - let mut changed = false; - // `None` between two non-literal arms (e.g. `int | None`) stays bare. - // We only know whether to attach a `None` to a Literal group once we - // see what follows it, so hold it pending until we see a non-None - // literal (attach forward) or anything else (flush as bare `None`). - let mut pending_none = false; - - for p in parts { - if matches!(p, Expr::NoneLiteral(_)) { - if let Some(Group::Literals(list)) = groups.last_mut() { - list.push("None".to_owned()); - } else { - pending_none = true; - } - } else if is_literal_expr(p, self.float_literals) { - let s = self.src(p.range()).to_owned(); - if pending_none { - pending_none = false; - match groups.last_mut() { - Some(Group::Literals(list)) => { - list.push("None".to_owned()); - list.push(s); - } - _ => groups.push(Group::Literals(vec!["None".to_owned(), s])), - } - } else if let Some(Group::Literals(list)) = groups.last_mut() { - list.push(s); - } else { - groups.push(Group::Literals(vec![s])); - } - changed = true; - } else { - if pending_none { - pending_none = false; - groups.push(Group::Other("None".to_owned())); - } - let rewritten = self.transform_type_expr(p, false); - if rewritten.is_some() { - changed = true; - } - let s = rewritten.unwrap_or_else(|| self.src(p.range()).to_owned()); - groups.push(Group::Other(s)); - } - } - if pending_none { - groups.push(Group::Other("None".to_owned())); - } - - if !changed { - return None; - } - if groups.iter().any(|g| matches!(g, Group::Literals(_))) { - self.needs_literal_import = true; - } - - let out: Vec = groups - .into_iter() - .map(|g| match g { - Group::Literals(list) => format!("Literal[{}]", list.join(", ")), - Group::Other(s) => s, - }) - .collect(); - Some(out.join(" | ")) - } - - fn transform_subscript(&mut self, s: &ExprSubscript) -> Option { - // `Literal[...]` is already in literal context — its slice doesn't - // need re-wrapping. - if self.is_literal_name(&s.value) { - return None; - } - if self.is_annotated_name(&s.value) { - return self.transform_annotated_subscript(s); - } - if !self.is_type_subscript(&s.value) { - return None; - } - - let slice = s.slice.as_ref(); - // Unparenthesized tuple → multiple type args (e.g. `dict[str, int]`). - if let Expr::Tuple(t) = slice { - if !t.parenthesized { - let rewrites: Vec> = t - .elts - .iter() - .map(|e| { - if matches!(e, Expr::StringLiteral(_)) { - None - } else { - self.transform_type_expr(e, false) - } - }) - .collect(); - if !rewrites.iter().any(std::option::Option::is_some) { - return None; - } - let parts: Vec = rewrites - .into_iter() - .zip(t.elts.iter()) - .map(|(r, e)| r.unwrap_or_else(|| self.src(e.range()).to_owned())) - .collect(); - let value_src = self.src(s.value.range()); - return Some(format!("{value_src}[{}]", parts.join(", "))); - } - } - - // A bare string literal in a generic-class subscript slot is a PEP - // 484 forward reference — leave it alone instead of wrapping in - // `Literal[...]`. (`Literal["X"]` and `Annotated["X", ...]` are - // already handled by the early returns above.) - if matches!(slice, Expr::StringLiteral(_)) { - return None; - } - - let rewrite = self.transform_type_expr(slice, false)?; - let value_src = self.src(s.value.range()); - Some(format!("{value_src}[{rewrite}]")) - } - - /// `Annotated[T, meta...]` — only the first arg is a type position; the - /// rest is arbitrary metadata and must not be rewritten. - fn transform_annotated_subscript(&mut self, s: &ExprSubscript) -> Option { - let Expr::Tuple(t) = s.slice.as_ref() else { - return None; - }; - if t.parenthesized || t.elts.is_empty() { - return None; - } - let first_rewrite = self.transform_type_expr(&t.elts[0], false)?; - let mut parts = vec![first_rewrite]; - for e in &t.elts[1..] { - parts.push(self.src(e.range()).to_owned()); - } - let value_src = self.src(s.value.range()); - Some(format!("{value_src}[{}]", parts.join(", "))) - } - - /// Emit minimal edits for literal type rewrites. Unlike `transform_type_expr` - /// which returns a full string replacement, this method emits one edit per - /// contiguous literal group, leaving non-literal parts (e.g. class self-refs - /// handled by `auto_quote`) at their own ranges so they don't get subsumed. pub(crate) fn emit_type_edits(&mut self, expr: &Expr, _at_root: bool) { // bare `None` is idiomatic for `NoneType` in any type position — // never wrap with `Literal[None]`. union-arm `None`s adjacent to a @@ -514,25 +329,6 @@ pub(crate) fn literal_already_imported(types: &dyn TypeInfo) -> bool { types.is_bound_globally("Literal") } -/// Stateless rewrite of a type expression, for use by other transforms that -/// need to splice rewritten type text into their own output (e.g. -/// `generics.rs` when wrapping a type alias body in `TypeAliasType(...)`). -/// -/// Doesn't update any "needs Literal import" flag — call -/// `LiteralType::visit_stmt` separately for that. The rewrite returned here -/// uses the original source text for sub-expressions, so callers must not -/// then apply incompatible edits (like name renames) on top of overlapping -/// ranges. -pub(crate) fn rewrite_type_expr( - source: &str, - types: &dyn TypeInfo, - expr: &Expr, - float_literals: FloatLiteralLowering, -) -> Option { - let mut t = LiteralType::new(source, types, float_literals); - t.transform_type_expr(expr, true) -} - #[cfg(test)] mod tests { use crate::config::FloatLiteralLowering; diff --git a/crates/ruff_linter/resources/test/fixtures/basedpython/BY023.by b/crates/ruff_linter/resources/test/fixtures/basedpython/BY023.by new file mode 100644 index 0000000000..61ba807de4 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/basedpython/BY023.by @@ -0,0 +1,64 @@ +from typing import TypeAlias + +point: tuple[int, int] +single: tuple[int] +already_comma: tuple[int,] +unpacked: tuple[*Ts] +after_a_prefix: tuple[int, *Ts] +nested: tuple[int, tuple[str, float]] +in_a_union: tuple[int, str] | None +in_a_subscript: list[tuple[int, str]] + +multiline: tuple[ + int, # a comment among the elements survives + str, +] + + +def f(x: tuple[int, str]) -> tuple[bool]: ... + + +def g[T: tuple[int, str], U = tuple[str]](y: T) -> U: ... + + +type Alias = tuple[int, str] +Legacy: TypeAlias = tuple[int, str] + + +class C[T: tuple[int, str]](tuple[str, int]): + field: tuple[int, str] + + +# a homogeneous tuple is spelled `(*: int)`, which is not this rule's rewrite +homogeneous: tuple[int, ...] + +# only the outer tuple here: the element list is rewritable, and the homogeneous +# tuple unpacked into it stays as it is +unpacked_homogeneous: tuple[int, *tuple[str, ...]] + +# `tuple[()]` is the empty tuple, which basedpython does spell `()` — the slice is +# already a parenthesized tuple, and the rewrite has nothing left to do +empty: tuple[()] +# likewise a slice written with its own parentheses +parenthesized: tuple[(int, str)] + +# `typing.Tuple` is left to UP006, which rewrites it to the builtin; this rule +# then reports what that produced +from typing import Tuple + +legacy: Tuple[int, str] + +# a bare `tuple` is not subscripted +bare: tuple + +# a string annotation is never lowered, so the quotes must keep the python form +quoted: "tuple[int, str]" + +# value positions are plain python +alias_value = tuple[int, str] +print(tuple[int, str]) + + +def shadowed(): + tuple = list + shadow: tuple[int, str] diff --git a/crates/ruff_linter/resources/test/fixtures/basedpython/BY023.py b/crates/ruff_linter/resources/test/fixtures/basedpython/BY023.py new file mode 100644 index 0000000000..756e70eff3 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/basedpython/BY023.py @@ -0,0 +1,4 @@ +# a parenthesized tuple type is a parse error in a .py file, so nothing here is +# rewritable +point: tuple[int, int] +single: tuple[int] diff --git a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs index 308b647b39..33c3cf36ed 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs @@ -32,6 +32,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) { if checker.is_rule_enabled(Rule::ManualTypeofAnnotation) { basedpython::rules::manual_typeof_annotation(checker, subscript); } + if checker.is_rule_enabled(Rule::ManualTupleAnnotation) { + basedpython::rules::manual_tuple_annotation(checker, subscript); + } // Ex) Optional[...], Union[...] if checker.any_rule_enabled(&[ Rule::FutureRewritableTypeAnnotation, diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index b3add49485..4478712d32 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -1290,6 +1290,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleStatus, Rule)> { (Basedpython, "020") => rules::basedpython::rules::ManualCastCall, (Basedpython, "021") => rules::basedpython::rules::ManualProperty, (Basedpython, "022") => rules::basedpython::rules::ManualModifier, + (Basedpython, "023") => rules::basedpython::rules::ManualTupleAnnotation, (Basedpython, "101") => rules::basedpython::rules::RedundantNoneCoalesce, // airflow diff --git a/crates/ruff_linter/src/rules/basedpython/mod.rs b/crates/ruff_linter/src/rules/basedpython/mod.rs index d10bd39e01..67dc35afad 100644 --- a/crates/ruff_linter/src/rules/basedpython/mod.rs +++ b/crates/ruff_linter/src/rules/basedpython/mod.rs @@ -36,6 +36,8 @@ mod tests { #[test_case(Rule::ManualCastCall, Path::new("BY020.by"))] #[test_case(Rule::ManualProperty, Path::new("BY021.by"))] #[test_case(Rule::ManualModifier, Path::new("BY022.by"))] + #[test_case(Rule::ManualTupleAnnotation, Path::new("BY023.by"))] + #[test_case(Rule::ManualTupleAnnotation, Path::new("BY023.py"))] #[test_case(Rule::RedundantNoneCoalesce, Path::new("BY101.by"))] fn rules(rule_code: Rule, path: &Path) -> Result<()> { let snapshot = format!( diff --git a/crates/ruff_linter/src/rules/basedpython/rules/manual_tuple_annotation.rs b/crates/ruff_linter/src/rules/basedpython/rules/manual_tuple_annotation.rs new file mode 100644 index 0000000000..a41001b174 --- /dev/null +++ b/crates/ruff_linter/src/rules/basedpython/rules/manual_tuple_annotation.rs @@ -0,0 +1,189 @@ +use ruff_macros::{ViolationMetadata, derive_message_formats}; +use ruff_python_ast::token::TokenKind; +use ruff_python_ast::{self as ast, Expr}; +use ruff_python_semantic::SemanticModel; +use ruff_text_size::{Ranged, TextRange, TextSize}; + +use crate::checkers::ast::Checker; +use crate::codes::Category; +use crate::{AlwaysFixableViolation, Applicability, Edit, Fix}; + +/// ## What it does +/// Checks for a subscripted `tuple` in a `.by` type position, which +/// basedpython spells as a parenthesized list of element types. +/// +/// ## Why is this bad? +/// `(int, str)` is basedpython's own tuple type, and reads as the value it +/// describes. `tuple[int, str]` is the python spelling of the same type, so +/// writing it only makes the annotation longer. +/// +/// ## Example +/// ```by +/// point: tuple[int, int] +/// +/// def head(xs: list[tuple[str, int]]) -> tuple[str]: ... +/// ``` +/// +/// Use instead: +/// ```by +/// point: (int, int) +/// +/// def head(xs: list[(str, int)]) -> (str,): ... +/// ``` +/// +/// A homogeneous `tuple[T, ...]` has no parenthesized form — basedpython +/// spells it with the [variadic](https://docs.basedpython.org/features/tuple-types) +/// `(*: T)` — so this rule leaves it alone. +/// +/// ## Fix safety +/// Only the `tuple[` and the closing `]` are rewritten, so the elements keep +/// their layout. The fix is marked as unsafe when the annotation contains a +/// comment: a tuple type is re-rendered when it is lowered, so the comment +/// stays in the `.by` source but no longer reaches the transpiled python. +/// +/// Only the builtin `tuple` is reported. `typing.Tuple` is left to `UP006`, +/// which rewrites it to the builtin; this rule then reports what that produced. +/// +/// ## References +/// - [basedpython documentation: tuple type literals](https://docs.basedpython.org/features/tuple-types) +#[derive(ViolationMetadata)] +#[violation_metadata(stable_since = "0.0.1-a10", category = Category::Style)] +pub(crate) struct ManualTupleAnnotation; + +impl AlwaysFixableViolation for ManualTupleAnnotation { + #[derive_message_formats] + fn message(&self) -> String { + "`tuple[…]` can be written as `(…)`".to_string() + } + + fn fix_title(&self) -> String { + "Replace with `(…)`".to_string() + } +} + +/// BY023 +pub(crate) fn manual_tuple_annotation(checker: &Checker, subscript: &ast::ExprSubscript) { + if !checker.source_type.is_basedpython() { + return; + } + if !in_tuple_type_position(checker.semantic()) { + return; + } + if !checker + .semantic() + .match_builtin_expr(&subscript.value, "tuple") + { + return; + } + let Some(elements) = tuple_elements(&subscript.slice) else { + return; + }; + let Some(last) = elements.last() else { + return; + }; + + let Some(open_end) = open_bracket_end(checker, subscript) else { + return; + }; + let close_start = subscript.end() - TextSize::from(1); + let head = TextRange::new(subscript.start(), open_end); + let tail = TextRange::new(close_start, subscript.end()); + + // `tuple[int]` → `(int,)`: a lone element needs the trailing comma to tell + // the tuple type apart from a parenthesized expression. an unpacked element + // (`tuple[*A]`) is unambiguous without one, but the formatter writes the + // comma there too, so the rewrite matches what a formatted file looks like + let closing = if elements.len() == 1 + && !has_trailing_comma(checker, TextRange::new(last.end(), close_start)) + { + ",)" + } else { + ")" + }; + + // a tuple type is re-rendered when it is lowered, so a comment among the + // elements survives in the `.by` source but not in the transpiled python + let applicability = if checker.comment_ranges().intersects(subscript.range()) { + Applicability::Unsafe + } else { + Applicability::Safe + }; + + checker + .report_diagnostic(ManualTupleAnnotation, subscript.range()) + .set_fix(Fix::applicable_edits( + Edit::range_replacement("(".to_string(), head), + [Edit::range_replacement(closing.to_string(), tail)], + applicability, + )); +} + +/// True in the type positions where basedpython reads a parenthesized tuple as +/// a tuple type: an annotation, either spelling of a type alias value, and a +/// PEP 695 type parameter's bound or default. +/// +/// A class base is deliberately excluded. It is a runtime value position, where +/// `class C((str, int))` is a plain tuple literal that raises `TypeError`, +/// unlike `class C(tuple[str, int])`. A string annotation is excluded too: the +/// rewrite inside the quotes would never be lowered, leaving the emitted python +/// with a tuple expression where a type belongs. +fn in_tuple_type_position(semantic: &SemanticModel) -> bool { + if semantic.in_string_type_definition() { + return false; + } + semantic.in_annotation() + || semantic.in_type_alias_value() + || semantic.in_type_param_definition() +} + +/// The elements of a `tuple[…]` slice, or `None` when there is no rewrite to +/// make — either because the subscript has no parenthesized basedpython form, +/// or because the slice is already written as a parenthesized tuple. +fn tuple_elements(slice: &Expr) -> Option> { + let elements = match slice { + // a slice already written as a parenthesized tuple: `tuple[(int, str)]`, + // and `tuple[()]`, which is how the empty tuple is spelled. both have a + // basedpython form, but the parentheses the rewrite would add are + // already there, so there is nothing to report + Expr::Tuple(tuple) if tuple.parenthesized => return None, + Expr::Tuple(tuple) => tuple.elts.iter().collect::>(), + other => vec![other], + }; + if elements.is_empty() { + return None; + } + // a homogeneous `tuple[T, ...]` is spelled `(*: T)`, not with a + // parenthesized list. an ellipsis anywhere else is not a type at all, so + // neither form applies + if elements + .iter() + .any(|element| matches!(element, Expr::EllipsisLiteral(_) | Expr::Slice(_))) + { + return None; + } + Some(elements) +} + +/// The offset just past the `[` opening a subscript's slice. +fn open_bracket_end(checker: &Checker, subscript: &ast::ExprSubscript) -> Option { + checker + .tokens() + .in_range(TextRange::new(subscript.value.end(), subscript.end())) + .iter() + .find(|token| token.kind() == TokenKind::Lsqb) + .map(Ranged::end) +} + +/// Whether a `,` already sits in `range`, which spans from the last element to +/// the subscript's `]`. +/// +/// Read from the tokens rather than the text, so that a comma inside a trailing +/// comment is not mistaken for the tuple's own — appending a second one would +/// leave `(int, ,)`. +fn has_trailing_comma(checker: &Checker, range: TextRange) -> bool { + checker + .tokens() + .in_range(range) + .iter() + .any(|token| token.kind() == TokenKind::Comma) +} diff --git a/crates/ruff_linter/src/rules/basedpython/rules/mod.rs b/crates/ruff_linter/src/rules/basedpython/rules/mod.rs index 23e66db831..e2a9311c17 100644 --- a/crates/ruff_linter/src/rules/basedpython/rules/mod.rs +++ b/crates/ruff_linter/src/rules/basedpython/rules/mod.rs @@ -8,6 +8,7 @@ pub(crate) use manual_property::*; pub(crate) use manual_re_export::*; pub(crate) use manual_sentinel::*; pub(crate) use manual_super_call::*; +pub(crate) use manual_tuple_annotation::*; pub(crate) use manual_typeof_annotation::*; pub(crate) use manual_unpack_annotation::*; pub(crate) use redundant_none_coalesce::*; @@ -24,6 +25,7 @@ mod manual_property; mod manual_re_export; mod manual_sentinel; mod manual_super_call; +mod manual_tuple_annotation; mod manual_typeof_annotation; mod manual_unpack_annotation; mod redundant_none_coalesce; diff --git a/crates/ruff_linter/src/rules/basedpython/snapshots/ruff_linter__rules__basedpython__tests__BY023_BY023.by.snap b/crates/ruff_linter/src/rules/basedpython/snapshots/ruff_linter__rules__basedpython__tests__BY023_BY023.by.snap new file mode 100644 index 0000000000..ff433c2df3 --- /dev/null +++ b/crates/ruff_linter/src/rules/basedpython/snapshots/ruff_linter__rules__basedpython__tests__BY023_BY023.by.snap @@ -0,0 +1,314 @@ +--- +source: crates/ruff_linter/src/rules/basedpython/mod.rs +--- +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:3:8 + | +1 | from typing import TypeAlias +2 | +3 | point: tuple[int, int] + | ^^^^^^^^^^^^^^^ +4 | single: tuple[int] +5 | already_comma: tuple[int,] + | +help: Replace with `(…)` + | +2 | + - point: tuple[int, int] +3 + point: (int, int) +4 | single: tuple[int] + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:4:9 + | +3 | point: tuple[int, int] +4 | single: tuple[int] + | ^^^^^^^^^^ +5 | already_comma: tuple[int,] +6 | unpacked: tuple[*Ts] + | +help: Replace with `(…)` + | +3 | point: tuple[int, int] + - single: tuple[int] +4 + single: (int,) +5 | already_comma: tuple[int,] + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:5:16 + | +3 | point: tuple[int, int] +4 | single: tuple[int] +5 | already_comma: tuple[int,] + | ^^^^^^^^^^^ +6 | unpacked: tuple[*Ts] +7 | after_a_prefix: tuple[int, *Ts] + | +help: Replace with `(…)` + | +4 | single: tuple[int] + - already_comma: tuple[int,] +5 + already_comma: (int,) +6 | unpacked: tuple[*Ts] + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:6:11 + | +4 | single: tuple[int] +5 | already_comma: tuple[int,] +6 | unpacked: tuple[*Ts] + | ^^^^^^^^^^ +7 | after_a_prefix: tuple[int, *Ts] +8 | nested: tuple[int, tuple[str, float]] + | +help: Replace with `(…)` + | +5 | already_comma: tuple[int,] + - unpacked: tuple[*Ts] +6 + unpacked: (*Ts,) +7 | after_a_prefix: tuple[int, *Ts] + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:7:17 + | +5 | already_comma: tuple[int,] +6 | unpacked: tuple[*Ts] +7 | after_a_prefix: tuple[int, *Ts] + | ^^^^^^^^^^^^^^^ +8 | nested: tuple[int, tuple[str, float]] +9 | in_a_union: tuple[int, str] | None + | +help: Replace with `(…)` + | +6 | unpacked: tuple[*Ts] + - after_a_prefix: tuple[int, *Ts] +7 + after_a_prefix: (int, *Ts) +8 | nested: tuple[int, tuple[str, float]] + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:8:9 + | + 6 | unpacked: tuple[*Ts] + 7 | after_a_prefix: tuple[int, *Ts] + 8 | nested: tuple[int, tuple[str, float]] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 9 | in_a_union: tuple[int, str] | None +10 | in_a_subscript: list[tuple[int, str]] + | +help: Replace with `(…)` + | +7 | after_a_prefix: tuple[int, *Ts] + - nested: tuple[int, tuple[str, float]] +8 + nested: (int, tuple[str, float]) +9 | in_a_union: tuple[int, str] | None + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:8:20 + | + 6 | unpacked: tuple[*Ts] + 7 | after_a_prefix: tuple[int, *Ts] + 8 | nested: tuple[int, tuple[str, float]] + | ^^^^^^^^^^^^^^^^^ + 9 | in_a_union: tuple[int, str] | None +10 | in_a_subscript: list[tuple[int, str]] + | +help: Replace with `(…)` + | +7 | after_a_prefix: tuple[int, *Ts] + - nested: tuple[int, tuple[str, float]] +8 + nested: tuple[int, (str, float)] +9 | in_a_union: tuple[int, str] | None + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:9:13 + | + 7 | after_a_prefix: tuple[int, *Ts] + 8 | nested: tuple[int, tuple[str, float]] + 9 | in_a_union: tuple[int, str] | None + | ^^^^^^^^^^^^^^^ +10 | in_a_subscript: list[tuple[int, str]] + | +help: Replace with `(…)` + | +8 | nested: tuple[int, tuple[str, float]] + - in_a_union: tuple[int, str] | None +9 + in_a_union: (int, str) | None +10 | in_a_subscript: list[tuple[int, str]] + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:10:22 + | + 8 | nested: tuple[int, tuple[str, float]] + 9 | in_a_union: tuple[int, str] | None +10 | in_a_subscript: list[tuple[int, str]] + | ^^^^^^^^^^^^^^^ +11 | +12 | multiline: tuple[ + | +help: Replace with `(…)` + | +9 | in_a_union: tuple[int, str] | None + - in_a_subscript: list[tuple[int, str]] +10 + in_a_subscript: list[(int, str)] +11 | + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:12:12 + | +10 | in_a_subscript: list[tuple[int, str]] +11 | +12 | multiline: tuple[ + | ____________^ +13 | | int, # a comment among the elements survives +14 | | str, +15 | | ] + | |_^ +help: Replace with `(…)` + | +11 | + - multiline: tuple[ +12 + multiline: ( +13 | int, # a comment among the elements survives +14 | str, + - ] +15 + ) +16 | + | +note: This is an unsafe fix and may change runtime behavior + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:18:10 + | +18 | def f(x: tuple[int, str]) -> tuple[bool]: ... + | ^^^^^^^^^^^^^^^ +help: Replace with `(…)` + | +17 | + - def f(x: tuple[int, str]) -> tuple[bool]: ... +18 + def f(x: (int, str)) -> tuple[bool]: ... +19 | + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:18:30 + | +18 | def f(x: tuple[int, str]) -> tuple[bool]: ... + | ^^^^^^^^^^^ +help: Replace with `(…)` + | +17 | + - def f(x: tuple[int, str]) -> tuple[bool]: ... +18 + def f(x: tuple[int, str]) -> (bool,): ... +19 | + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:21:10 + | +21 | def g[T: tuple[int, str], U = tuple[str]](y: T) -> U: ... + | ^^^^^^^^^^^^^^^ +help: Replace with `(…)` + | +20 | + - def g[T: tuple[int, str], U = tuple[str]](y: T) -> U: ... +21 + def g[T: (int, str), U = tuple[str]](y: T) -> U: ... +22 | + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:21:31 + | +21 | def g[T: tuple[int, str], U = tuple[str]](y: T) -> U: ... + | ^^^^^^^^^^ +help: Replace with `(…)` + | +20 | + - def g[T: tuple[int, str], U = tuple[str]](y: T) -> U: ... +21 + def g[T: tuple[int, str], U = (str,)](y: T) -> U: ... +22 | + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:24:14 + | +24 | type Alias = tuple[int, str] + | ^^^^^^^^^^^^^^^ +25 | Legacy: TypeAlias = tuple[int, str] + | +help: Replace with `(…)` + | +23 | + - type Alias = tuple[int, str] +24 + type Alias = (int, str) +25 | Legacy: TypeAlias = tuple[int, str] + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:25:21 + | +24 | type Alias = tuple[int, str] +25 | Legacy: TypeAlias = tuple[int, str] + | ^^^^^^^^^^^^^^^ +help: Replace with `(…)` + | +24 | type Alias = tuple[int, str] + - Legacy: TypeAlias = tuple[int, str] +25 + Legacy: TypeAlias = (int, str) +26 | + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:28:12 + | +28 | class C[T: tuple[int, str]](tuple[str, int]): + | ^^^^^^^^^^^^^^^ +29 | field: tuple[int, str] + | +help: Replace with `(…)` + | +27 | + - class C[T: tuple[int, str]](tuple[str, int]): +28 + class C[T: (int, str)](tuple[str, int]): +29 | field: tuple[int, str] + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:29:12 + | +28 | class C[T: tuple[int, str]](tuple[str, int]): +29 | field: tuple[int, str] + | ^^^^^^^^^^^^^^^ +help: Replace with `(…)` + | +28 | class C[T: tuple[int, str]](tuple[str, int]): + - field: tuple[int, str] +29 + field: (int, str) +30 | + | + +BY023 [*] `tuple[…]` can be written as `(…)` + --> BY023.by:37:23 + | +35 | # only the outer tuple here: the element list is rewritable, and the homogeneous +36 | # tuple unpacked into it stays as it is +37 | unpacked_homogeneous: tuple[int, *tuple[str, ...]] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +38 | +39 | # `tuple[()]` is the empty tuple, which basedpython does spell `()` — the slice is + | +help: Replace with `(…)` + | +36 | # tuple unpacked into it stays as it is + - unpacked_homogeneous: tuple[int, *tuple[str, ...]] +37 + unpacked_homogeneous: (int, *tuple[str, ...]) +38 | + | diff --git a/crates/ruff_linter/src/rules/basedpython/snapshots/ruff_linter__rules__basedpython__tests__BY023_BY023.py.snap b/crates/ruff_linter/src/rules/basedpython/snapshots/ruff_linter__rules__basedpython__tests__BY023_BY023.py.snap new file mode 100644 index 0000000000..90410dc3a8 --- /dev/null +++ b/crates/ruff_linter/src/rules/basedpython/snapshots/ruff_linter__rules__basedpython__tests__BY023_BY023.py.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/basedpython/mod.rs +--- + diff --git a/crates/ruff_linter/src/settings/mod.rs b/crates/ruff_linter/src/settings/mod.rs index 0b4fb9f0f1..c07e10916d 100644 --- a/crates/ruff_linter/src/settings/mod.rs +++ b/crates/ruff_linter/src/settings/mod.rs @@ -1007,6 +1007,7 @@ mod tests { manual-cast-call (BY020), manual-property (BY021), manual-modifier (BY022), + manual-tuple-annotation (BY023), redundant-none-coalesce (BY101), fast-api-redundant-response-model (FAST001), fast-api-non-annotated-dependency (FAST002), diff --git a/crates/ruff_python_semantic/src/model.rs b/crates/ruff_python_semantic/src/model.rs index 818b0afcfb..369830232e 100644 --- a/crates/ruff_python_semantic/src/model.rs +++ b/crates/ruff_python_semantic/src/model.rs @@ -2459,6 +2459,20 @@ impl<'a> SemanticModel<'a> { self.flags.intersects(SemanticModelFlags::TYPE_ALIAS) } + /// Return `true` if the model is visiting a [PEP 695] type parameter + /// definition: a type parameter's bound or default, or a type alias value. + /// + /// For example: + /// ```python + /// class C[T: int]: ... # We're visiting the bound + /// ``` + /// + /// [PEP 695]: https://peps.python.org/pep-0695/ + pub const fn in_type_param_definition(&self) -> bool { + self.flags + .intersects(SemanticModelFlags::TYPE_PARAM_DEFINITION) + } + /// Return `true` if the model is in an exception handler. pub const fn in_exception_handler(&self) -> bool { self.flags.intersects(SemanticModelFlags::EXCEPTION_HANDLER) diff --git a/docs/basedpython/features/linter.md b/docs/basedpython/features/linter.md index 02e89d97d2..276e6ae5f5 100644 --- a/docs/basedpython/features/linter.md +++ b/docs/basedpython/features/linter.md @@ -27,6 +27,7 @@ spelling of something basedpython has syntax for: | `BY020` | `manual-cast-call` | a `typing.cast` call, which is the [`cast`](cast.md) keyword | | `BY021` | `manual-property` | a `@property`, which is a [declaration with accessors](properties.md) | | `BY022` | `manual-modifier` | a decorator that is a [modifier keyword](modifiers.md) | +| `BY023` | `manual-tuple-annotation` | a `tuple[…]` annotation, which is a [tuple type](tuple-types.md) | | `BY101` | `redundant-none-coalesce` | a `??` whose fallback cannot change the result | every one of them is fixable. `BY020`'s fix is the only one that is always @@ -35,7 +36,8 @@ is a no-op, so the rewrite adds a way for the program to fail. `BY021` is the only one that sometimes has no fix to offer. an accessor body is re-rendered when it is lowered and does not keep a comment, so a property with a -comment in it is reported and left for you to move. +comment in it is reported and left for you to move. a tuple type is re-rendered +the same way, so `BY023`'s fix is unsafe on an annotation with a comment in it. they also compose with the upstream rules that produce their input. `SIM108` turns an `if` / `else` block into a conditional expression, and `BY001` takes it diff --git a/ruff.schema.json b/ruff.schema.json index 2b01128120..0a523a68e0 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -3313,6 +3313,7 @@ "BY020", "BY021", "BY022", + "BY023", "BY1", "BY10", "BY101", @@ -5001,6 +5002,7 @@ "manual-re-export", "manual-sentinel", "manual-super-call", + "manual-tuple-annotation", "manual-typeof-annotation", "manual-unpack-annotation", "map-int-version-parsing", From f56d80f3b896908af05976992f67ab611ab84c7e Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:55:27 +1000 Subject: [PATCH 05/11] stop parsing an expression once a statement expression's suite ends it Co-Authored-By: Claude Opus 5 --- .../src/parser/expression.rs | 27 +++++++- crates/ruff_python_parser/src/parser/mod.rs | 8 +++ .../basedpython_statement_expressions.md | 65 +++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/crates/ruff_python_parser/src/parser/expression.rs b/crates/ruff_python_parser/src/parser/expression.rs index 0a1aff0197..9d111825ad 100644 --- a/crates/ruff_python_parser/src/parser/expression.rs +++ b/crates/ruff_python_parser/src/parser/expression.rs @@ -152,6 +152,19 @@ impl<'src> Parser<'src> { self.current_token_kind().is_soft_keyword() } + /// basedpython: whether the expression just parsed ended with a suite, which + /// makes it the last thing in the statement it belongs to. + /// + /// A suite swallows the newline that terminates its statement, so the parser + /// is left on the first token of the *next* line. Every continuation the + /// expression parser would otherwise accept there — a conditional + /// expression's `if`, a binary operator, a call's `(`, a subscript's `[` — + /// would splice that next statement onto this one, so each of them stops + /// when this holds. + const fn expression_ended_with_suite(&self) -> bool { + self.expr_consumed_suite + } + /// Returns `true` if the current token is the start of an expression. pub(super) fn at_expr(&self) -> bool { self.at_ts(EXPR_SET) @@ -385,7 +398,7 @@ impl<'src> Parser<'src> { let start = self.node_start(); let parsed_expr = self.parse_conditional_expression_or_higher_impl(context); - if self.at(TokenKind::Comma) { + if self.at(TokenKind::Comma) && !self.expression_ended_with_suite() { let subsequent_context = context.disallow_yield_expressions(); Expr::Tuple(self.parse_tuple_expression( parsed_expr.expr, @@ -415,7 +428,7 @@ impl<'src> Parser<'src> { let start = self.node_start(); let parsed_expr = self.parse_conditional_expression_or_higher_impl(context); - if self.at(TokenKind::ColonEqual) { + if self.at(TokenKind::ColonEqual) && !self.expression_ended_with_suite() { Expr::Named(self.parse_named_expression(parsed_expr.expr, start)).into() } else { parsed_expr @@ -448,7 +461,7 @@ impl<'src> Parser<'src> { let start = self.node_start(); let parsed_expr = self.parse_simple_expression(context); - if self.at(TokenKind::If) { + if self.at(TokenKind::If) && !self.expression_ended_with_suite() { Expr::If(self.parse_if_expression(parsed_expr.expr, start, context)).into() } else { parsed_expr @@ -499,6 +512,10 @@ impl<'src> Parser<'src> { loop { progress.assert_progressing(self); + if self.expression_ended_with_suite() { + break; + } + let current_token = self.current_token_kind(); if matches!(current_token, TokenKind::In) && context.is_in_excluded() { @@ -880,6 +897,10 @@ impl<'src> Parser<'src> { let lhs = self.parse_atom(context); + if self.expression_ended_with_suite() { + return lhs; + } + ParsedExpr { expr: self.parse_postfix_expression(lhs.expr, start, context), is_parenthesized: lhs.is_parenthesized, diff --git a/crates/ruff_python_parser/src/parser/mod.rs b/crates/ruff_python_parser/src/parser/mod.rs index 885a704bba..7ba3f9c753 100644 --- a/crates/ruff_python_parser/src/parser/mod.rs +++ b/crates/ruff_python_parser/src/parser/mod.rs @@ -124,6 +124,14 @@ pub(crate) struct Parser<'src> { /// just parsed as part of a simple statement swallowed that statement's /// terminating newline along with its suite. The simple-statement parsers /// take this flag instead of demanding a newline of their own. + /// + /// It is *live* only between the suite being consumed and the enclosing + /// statement ending: the expression parser reads it to stop at the token the + /// suite left it on rather than splicing the next line onto the value, and + /// every simple-statement parser then clears it with [`std::mem::take`]. So + /// it is set by exactly two places, read by the expression parser through + /// [`Parser::expression_ended_with_suite`], and cleared by whichever + /// statement parser terminates the statement the suite ended. expr_consumed_suite: bool, /// basedpython: how many destructuring binders have been named so far. diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_statement_expressions.md b/crates/ty_python_semantic/resources/mdtest/basedpython_statement_expressions.md index c3d4aea545..38b65dc90e 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_statement_expressions.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_statement_expressions.md @@ -256,6 +256,71 @@ def f(s: str): reveal_type(a) # revealed: int ``` +## a suite ends the statement it is written in + +A suite runs to the end of its last line, taking with it the newline that would otherwise terminate +the statement the suite is written in. The line after it therefore begins a new statement, even when +that line opens with a token the expression parser would otherwise read as a continuation of the +value — here the `if` that would be a conditional expression anywhere else. + +```by +import json + +def f(text: str) -> object: + parsed = try: + json.loads(text) + except json.JSONDecodeError: + return None + if not isinstance(parsed, dict): + return None + return parsed +``` + +The other continuations are held apart the same way: a call's `(`, a subscript's `[`, a binary +operator, and a walrus each start the next statement rather than extending the one the suite ended. + +```by +def g(c: bool, xs: list[int]): + a = if c: + 1 + else: + 2 + (xs).append(a) + + b = if c: + 1 + else: + 2 + [b] + + d = if c: + 1 + else: + 2 + -d + + e = if c: + 1 + else: + 2 + (f := e) + reveal_type(f) # revealed: 1 | 2 +``` + +a comma is held apart too, so the line after a suite is a statement of its own rather than the tail +of a tuple. `g` is bound to the branch's value, and the `2,` below it is a statement in its own +right. + +```by +def h(c: bool): + g = if c: + 1 + else: + 2 + 2, 3 + reveal_type(g) # revealed: 1 | 2 +``` + ## narrowing inside a branch applies to its value ```by From c23c8cf347784e77cf25436fa7dc460de62f88f6 Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Mon, 7 Sep 2026 06:41:12 +1000 Subject: [PATCH 06/11] mangle the names a type parameter's bound and default refer to the pep 695 polyfill splices the source text of a bound or default into the `TypeVar(...)` call it emits, but every type parameter is renamed on the way out (`T` -> `_T`). a bound or default that names another type parameter therefore reached the output holding a name nothing defines, and the emitted module raised `NameError` on import, because a legacy `TypeVar` evaluates both arguments eagerly: _T = TypeVar("_T") _R = TypeVar("_R", bound=T) this was already broken for defaults: `def g[T, R = T]` is valid pep 696 that the checker accepts today. the rename map now covers the enclosing type-parameter lists as well as the current one, so a method's bound can name its class's parameter. the runtime test executes the polyfilled module, because a text assertion on the lowered output cannot see an import that raises. Co-Authored-By: Claude Opus 5 --- .../by_transforms/src/transforms/generics.rs | 119 +++++++++++++++--- .../by_transforms/tests/polyfill_runtime.rs | 46 +++++++ 2 files changed, 150 insertions(+), 15 deletions(-) diff --git a/crates/by_transforms/src/transforms/generics.rs b/crates/by_transforms/src/transforms/generics.rs index ed91b929e6..24702a08d7 100644 --- a/crates/by_transforms/src/transforms/generics.rs +++ b/crates/by_transforms/src/transforms/generics.rs @@ -52,6 +52,11 @@ pub(crate) struct GenericPolyfill<'src> { /// alias value is subsumed by this pass's whole-statement replacement, so /// the rename has to be reapplied there private_aliases: HashMap, + /// the `T`→`_T` maps of the type-parameter lists this pass is currently inside, + /// outermost first. a bound or default may name a parameter of an enclosing list + /// (`class Owner[T]: def narrow[U: T]`), and that name is mangled by the enclosing + /// list, not by the one being processed + enclosing_renames: Vec>, /// `(range, rendered)` for every symbolic fold in the module. A fold inside a /// statement this pass replaces wholesale is dropped unless spliced in here symbolic_substitutions: Vec<(TextRange, String)>, @@ -158,6 +163,7 @@ impl<'src> GenericPolyfill<'src> { parameters_targets: HashSet::new(), needed_imports_any: false, generic_class_renames: HashMap::new(), + enclosing_renames: Vec::new(), private_aliases: HashMap::new(), symbolic_substitutions, pending_edits, @@ -375,6 +381,16 @@ impl<'src> GenericPolyfill<'src> { let mut param_names: Vec = Vec::new(); let mut defs: Vec = Vec::new(); let mut renames: HashMap = HashMap::new(); + // a bound or default may name a type parameter that is already in scope — an earlier + // entry in this list, or one of an enclosing list. those names are mangled, so the text + // spliced into `bound=` / `default=` has to be mangled with them or the emitted module + // raises `NameError` on import. an inner list shadows an outer one, and this list's own + // entries are added as they are processed, so a bound only ever sees names declared + // before it + let mut visible: HashMap = HashMap::new(); + for enclosing in &self.enclosing_renames { + visible.extend(enclosing.iter().map(|(k, v)| (k.clone(), v.clone()))); + } for param in params { match param { @@ -388,6 +404,7 @@ impl<'src> GenericPolyfill<'src> { { let mangled = self.unique_typevar_name(name, "ParamSpec"); renames.insert(name.to_owned(), mangled.clone()); + visible.insert(name.to_owned(), mangled.clone()); defs.push(format!("{mangled} = ParamSpec(\"{mangled}\")")); self.needed_imports.paramspec = true; param_names.push(mangled.clone()); @@ -415,7 +432,7 @@ impl<'src> GenericPolyfill<'src> { other => self.src(other.range()).to_owned(), }; if !inner.is_empty() { - extra_args.push(inner); + extra_args.push(apply_renames_to_rendered(&inner, &visible)); } } else { // basedpython parameter-shape tuple bound — lower to @@ -453,7 +470,10 @@ impl<'src> GenericPolyfill<'src> { ) .unwrap_or_else(|| self.src(bound.range()).to_owned()) }; - extra_args.push(format!("bound={bound_src}")); + extra_args.push(format!( + "bound={}", + apply_renames_to_rendered(&bound_src, &visible) + )); } } @@ -469,7 +489,10 @@ impl<'src> GenericPolyfill<'src> { if self.config.min_version < PythonVersion::PY313 { self.needed_imports.typevar_needs_ext = true; } - extra_args.push(format!("default={default_src}")); + extra_args.push(format!( + "default={}", + apply_renames_to_rendered(&default_src, &visible) + )); } // basedpython variance keywords: forward `out`/`in`/`in out` @@ -496,6 +519,7 @@ impl<'src> GenericPolyfill<'src> { let signature_args = extra_args.join(", "); let mangled = self.unique_typevar_name(name, &signature_args); renames.insert(name.to_owned(), mangled.clone()); + visible.insert(name.to_owned(), mangled.clone()); let mut args: Vec = vec![format!("\"{mangled}\"")]; args.extend(extra_args); let def = format!("{mangled} = TypeVar({})", args.join(", ")); @@ -510,6 +534,7 @@ impl<'src> GenericPolyfill<'src> { let name = tvt.name.id.as_str(); let mangled = self.unique_typevar_name(name, "TypeVarTuple"); renames.insert(name.to_owned(), mangled.clone()); + visible.insert(name.to_owned(), mangled.clone()); defs.push(format!("{mangled} = TypeVarTuple(\"{mangled}\")")); self.needed_imports.typevar_tuple = true; self.needed_imports.unpack = true; @@ -529,6 +554,7 @@ impl<'src> GenericPolyfill<'src> { let name = ps.name.id.as_str(); let mangled = self.unique_typevar_name(name, "ParamSpec"); renames.insert(name.to_owned(), mangled.clone()); + visible.insert(name.to_owned(), mangled.clone()); defs.push(format!("{mangled} = ParamSpec(\"{mangled}\")")); self.needed_imports.paramspec = true; param_names.push(mangled.clone()); @@ -609,13 +635,13 @@ impl<'src> GenericPolyfill<'src> { } } - fn process_class(&mut self, class: &StmtClassDef) { + fn process_class(&mut self, class: &StmtClassDef) -> HashMap { let Some(tp) = &class.type_params else { // a based-enum variant lowers to a module-level subclass of the enum // with no type params of its own; rename the enum's params in its // field annotations using the enum's recorded map self.rename_variant_of_generic_enum(class); - return; + return HashMap::new(); }; if has_parameters_bound(&tp.type_params) { self.parameters_targets @@ -635,7 +661,7 @@ impl<'src> GenericPolyfill<'src> { tp.range().end(), ))); } - return; + return HashMap::new(); } let ProcessedTypeParams { @@ -712,6 +738,7 @@ impl<'src> GenericPolyfill<'src> { rename_in_stmt(stmt, &rename_map, &mut self.edits); } self.reconcile_pending(&rename_map, mark); + rename_map } /// Rename a generic enum's type params in a module-level variant subclass. @@ -732,14 +759,14 @@ impl<'src> GenericPolyfill<'src> { } } - fn process_function(&mut self, func: &StmtFunctionDef) { + fn process_function(&mut self, func: &StmtFunctionDef) -> HashMap { let Some(tp) = &func.type_params else { - return; + return HashMap::new(); }; // basedpython: a `type def` is erased by its own pass, so polyfilling its // type parameters would leave an orphan `TypeVar` behind if ruff_python_ast::helpers::is_type_def(func) { - return; + return HashMap::new(); } if has_parameters_bound(&tp.type_params) { self.parameters_targets @@ -748,7 +775,7 @@ impl<'src> GenericPolyfill<'src> { // PEP 695 function type params are native syntax in 3.12+ (3.13+ with defaults) if self.supports_native_type_params(&tp.type_params) { self.lower_type_param_bounds(&tp.type_params); - return; + return HashMap::new(); } let ProcessedTypeParams { @@ -800,6 +827,7 @@ impl<'src> GenericPolyfill<'src> { rename_in_stmt(stmt, &rename_map, &mut self.edits); } self.reconcile_pending(&rename_map, mark); + rename_map } fn process_type_alias(&mut self, alias: &StmtTypeAlias) { @@ -1013,16 +1041,25 @@ impl GenericPolyfill<'_> { impl<'ast> Visitor<'ast> for GenericPolyfill<'_> { fn visit_stmt(&mut self, stmt: &'ast Stmt) { - match stmt { - Stmt::ClassDef(class) => self.process_class(class), - Stmt::FunctionDef(func) => self.process_function(func), + // a nested type-parameter list can name a parameter of an enclosing one in its bounds + // and defaults, and that name carries the enclosing list's mangling, so the maps stay + // on a stack for the length of the walk through the body that declared them + let scoped_renames = match stmt { + Stmt::ClassDef(class) => Some(self.process_class(class)), + Stmt::FunctionDef(func) => Some(self.process_function(func)), Stmt::TypeAlias(alias) => { self.process_type_alias(alias); return; // don't recurse into the alias value } - _ => {} + _ => None, + }; + if let Some(renames) = scoped_renames { + self.enclosing_renames.push(renames); + walk_stmt(self, stmt); + self.enclosing_renames.pop(); + } else { + walk_stmt(self, stmt); } - walk_stmt(self, stmt); } fn visit_expr(&mut self, expr: &'ast Expr) { @@ -1515,6 +1552,58 @@ mod tests { ); } + #[test] + fn bound_naming_an_earlier_type_parameter() { + // the name a bound refers to is mangled by the list that declares it, so the text + // spliced into `bound=` has to be mangled too — an unrenamed `T` here is a `NameError` + // when the emitted module is imported, because the `TypeVar` call evaluates its bound + check( + indoc! {" + def f[T, R: T](t: T, r: R) -> None: ... + "}, + indoc! {" + from typing import TypeVar + _T = TypeVar(\"_T\") + _R = TypeVar(\"_R\", bound=_T) + def f(t: _T, r: _R) -> None: ... + "}, + ); + } + + #[test] + fn default_naming_an_earlier_type_parameter() { + check( + indoc! {" + def f[T, R = T](t: T, r: R) -> None: ... + "}, + indoc! {" + from typing_extensions import TypeVar + _T = TypeVar(\"_T\") + _R = TypeVar(\"_R\", default=_T) + def f(t: _T, r: _R) -> None: ... + "}, + ); + } + + #[test] + fn bound_naming_an_enclosing_type_parameter() { + // `T` belongs to the class's list, so the method's own list does not mangle it; the + // enclosing list's map has to reach the method's `TypeVar` call + check( + indoc! {" + class Owner[T]: + def narrow[U: T](self, u: U) -> None: ... + "}, + indoc! {" + from typing import TypeVar, Generic + _T = TypeVar(\"_T\") + class Owner(Generic[_T]): + _U = TypeVar(\"_U\", bound=_T) + def narrow(self, u: _U) -> None: ... + "}, + ); + } + #[test] fn class_default_typevar() { // Default-only TypeVar with literal default should also rewrite. diff --git a/crates/by_transforms/tests/polyfill_runtime.rs b/crates/by_transforms/tests/polyfill_runtime.rs index c5034e0954..ec36992495 100644 --- a/crates/by_transforms/tests/polyfill_runtime.rs +++ b/crates/by_transforms/tests/polyfill_runtime.rs @@ -77,6 +77,33 @@ assert Starred.__type_params__ != (), "the variadic reached `type_params`" print("ok") "#; +/// a bound or default naming another type parameter. the `TypeVar` call evaluates both, so an +/// unmangled name here is a `NameError` at import — which a text assertion on the lowered output +/// cannot see, because the text it asserts on is what raises +const DEPENDENT_BOUNDS: &str = r#" +def pick[T, R: T](t: T, r: R) -> R: + return r + +def fallback[T, R = T](t: T, r: R) -> R: + return r + +class Owner[T]: + def narrow[U: T](self, u: U) -> U: + return u + +assert pick(object(), 1) == 1, "a bound naming an earlier parameter imports" +assert fallback(object(), 1) == 1, "so does a default naming one" +assert Owner().narrow(2) == 2, "and a bound naming an enclosing list's parameter" + +# the bound has to resolve to the very TypeVar the earlier parameter emitted, not to some +# other object that merely happens to be in scope +import typing +hints = typing.get_type_hints(pick) +assert hints["r"].__bound__ == hints["t"], "the bound resolves to the earlier parameter" + +print("ok") +"#; + /// The polyfilled output imports `typing_extensions` for `TypeAliasType`, so an /// interpreter without it can only run the rename half. fn python_with_typing_extensions(python: &str) -> bool { @@ -137,3 +164,22 @@ fn polyfilled_aliases_and_bounds_run() { } run_polyfilled(&python, ALIASES); } + +#[test] +#[expect( + clippy::print_stderr, + reason = "a skipped test must say why it skipped, or it reads as a pass" +)] +fn polyfilled_dependent_bounds_run() { + let Some(python) = python() else { + eprintln!("skipping polyfill runtime test: no `python3` interpreter found"); + return; + }; + if !python_with_typing_extensions(&python) { + eprintln!( + "skipping polyfill dependent-bound runtime test: {python} has no `typing_extensions`" + ); + return; + } + run_polyfilled(&python, DEPENDENT_BOUNDS); +} From 4a11609c7da695bc045d1e533f5a34c1a12b9601 Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Mon, 7 Sep 2026 06:41:31 +1000 Subject: [PATCH 07/11] support a type parameter bound that names another type parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `def f[T, R: T]` is pep 695's own rule — a bound may name a type parameter already in scope, either an earlier entry in the same list or, for a method, an entry in its class's list. the checker reported `invalid-type-variable-bound` and then discarded the bound, so `r` had no members and every pair of arguments was accepted. the scope rule is decided in one place, `bound_scope_violation_for`, which both the diagnostic and `lazy_bound` consult. a bound that breaks it is still dropped, not merely reported: several consumers reduce a type variable to its bound by plain recursion, so the mutually recursive bounds of `def f[T: R, R: T]` would overflow the stack rather than produce an error. a nested class or nested function is rejected for the same reason its bound cannot work — nothing substitutes the enclosing parameter, so the generic would fail a bound it could never satisfy at every use. a bound naming another type parameter is a relation between two variables, and the constraint set already represents one, so it is conjoined there as a validity bound rather than measured as a concrete ceiling. inference is therefore bound-informed, as in java and typescript: `pick(Dog(), Animal())` widens `T` rather than failing, which is the only reading that also solves `def f[T, R: T](r: R) -> T`, where nothing else mentions `T`. every path that treats a declared bound as a concrete type leaves a generic one alone, an explicit specialization substitutes the arguments already chosen into it, and an unsatisfiable relation is reported against the argument that caused it once both variables are solved. where there is no specialization to consult, a bound is read at whichever end of each named parameter makes it widest, so a declaration is faulted only for what nothing could rescue. a bound range gets a second reading as well: `def f[T: str, R: T..int]` passes the first, because `T` could be `Never`, and was then rejected at every call from one side or the other. a variadic pack's bound describes its members rather than the pack's own value, so it never reaches the constraint set and cannot name a type parameter at all. Co-Authored-By: Claude Opus 5 --- crates/ty/docs/rules.md | 28 +- crates/ty_ide/src/completion.rs | 15 + .../lint_docs/invalid-type-variable-bound.md | 28 +- .../mdtest/basedpython_bound_ranges.md | 37 +- .../mdtest/generics/generic_bounds.md | 324 ++++++++++++++++++ .../mdtest/generics/pep695/aliases.md | 6 +- .../mdtest/generics/pep695/classes.md | 10 +- .../mdtest/generics/pep695/variables.md | 5 +- .../generics/typeddict_and_self_bounds.md | 8 +- ...rence\342\200\246_(f4cd31f00230338a).snap" | 15 +- crates/ty_python_semantic/src/types.rs | 10 +- .../ty_python_semantic/src/types/call/bind.rs | 135 +++++++- .../src/types/constraints.rs | 40 ++- .../ty_python_semantic/src/types/generics.rs | 145 +++++++- .../src/types/infer/builder.rs | 4 +- .../src/types/infer/builder/subscript.rs | 86 ++++- .../src/types/infer/builder/typevar.rs | 185 ++++++++-- .../src/types/overlapping.rs | 9 +- .../src/types/reified_infer.rs | 11 +- .../src/types/signatures.rs | 5 + .../ty_python_semantic/src/types/typevar.rs | 298 +++++++++++++++- docs/basedpython/features/bound-ranges.md | 14 +- docs/basedpython/features/generics.md | 47 +++ ty.schema.json | 2 +- 24 files changed, 1359 insertions(+), 108 deletions(-) create mode 100644 crates/ty_python_semantic/resources/mdtest/generics/generic_bounds.md rename "crates/ty_python_semantic/resources/mdtest/snapshots/classes.md_-_Generic_classes___PEP\342\200\246_-_Scoping_of_typevars_-_No_back-references_(9051beb16a623d36).snap" => "crates/ty_python_semantic/resources/mdtest/snapshots/classes.md_-_Generic_classes___PEP\342\200\246_-_Scoping_of_typevars_-_No_forward_reference\342\200\246_(f4cd31f00230338a).snap" (84%) diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 039f5a9f55..1c12ffbbc7 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -4558,12 +4558,21 @@ Added in 0.0.15 U: + return x + + # nothing settles `U` for a nested class + class Inner[T: U]: ... # error: [invalid-type-variable-bound] ``` [type variable]: https://docs.python.org/3/library/typing.html#typing.TypeVar diff --git a/crates/ty_ide/src/completion.rs b/crates/ty_ide/src/completion.rs index 5e83ecdcb6..0db6380f12 100644 --- a/crates/ty_ide/src/completion.rs +++ b/crates/ty_ide/src/completion.rs @@ -11333,6 +11333,21 @@ def f[T: str](msg: T): test.contains("capitalize"); } + #[test] + fn typevar_bounded_by_another_type_parameter() { + // a bound may name a type parameter that precedes it, and completions have to reach + // through both hops to the type that parameter is itself bounded by + let builder = completion_test_builder( + "\ +def f[T: str, R: T](msg: R): + msg. +", + ); + let test = builder.build(); + test.contains("upper"); + test.contains("capitalize"); + } + #[test] fn typevar_with_constraints() { // Test TypeVar with constraints diff --git a/crates/ty_python_semantic/resources/lint_docs/invalid-type-variable-bound.md b/crates/ty_python_semantic/resources/lint_docs/invalid-type-variable-bound.md index 51f73cf05f..02836ad5b1 100644 --- a/crates/ty_python_semantic/resources/lint_docs/invalid-type-variable-bound.md +++ b/crates/ty_python_semantic/resources/lint_docs/invalid-type-variable-bound.md @@ -1,10 +1,19 @@ ## What it does -Checks for [type variables][type variable] whose bounds reference type variables. +Checks for [type variables][type variable] whose bounds reference type variables that are not in +scope where the bound is written. ## Why is this bad? -The bound of a type variable must be a concrete type. +A type parameter's bound may reference a type parameter that is already in scope *and* substituted +where the bound is used: an earlier entry in the same type parameter list, or — for a method — an +entry in its class's list, which binding the receiver settles. + +Anything else has nothing to resolve to. A later entry is not yet in scope, a parameter is not in +scope inside its own bound, and a legacy `TypeVar` is declared by an assignment, so it has no list +to hold a position in. A nested class or nested function is in scope but is never substituted, so +the reference would still be standing at every use of the generic. A variadic pack's bound describes +its members rather than the pack's own value, so it cannot reference a type parameter at all. ## Examples @@ -24,7 +33,20 @@ BoundT = TypeVar("BoundT", bound=U) def f[T: list[T]](): ... # error: [invalid-type-variable-bound] -def g[U, T: U](): ... # error: [invalid-type-variable-bound] +def g[T: U, U](): ... # error: [invalid-type-variable-bound] + + +# `U` precedes `T`, so `T`'s bound can name it +def h[U, T: U](x: U, y: T): ... + + +class Owner[U]: + # the receiver settles `U`, so a method's bound can name it + def narrow[T: U](self, x: T) -> U: + return x + + # nothing settles `U` for a nested class + class Inner[T: U]: ... # error: [invalid-type-variable-bound] ``` [type variable]: https://docs.python.org/3/library/typing.html#typing.TypeVar diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_bound_ranges.md b/crates/ty_python_semantic/resources/mdtest/basedpython_bound_ranges.md index 1bae73cea4..a99b5a3453 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_bound_ranges.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_bound_ranges.md @@ -164,13 +164,46 @@ a parameter list is not a type, so it cannot cap a range of types. class C[T: int..(*: *, **: *)]: ... ``` -## a generic lower bound is rejected +## either end can name a type parameter already in scope + +both ends follow the same scope rule as a plain upper bound: they may name a type parameter that +precedes them in the same list, or one belonging to an enclosing list. ```by class C[T]: - # error: [invalid-type-variable-bound] "TypeVar lower bound cannot be generic" def f[U: T..object](self, x: U) -> U: return x + +def g[T, U: T..object](t: T, u: U) -> U: + return u +``` + +a later parameter is not in scope yet, at either end. + +```by +# error: [invalid-type-variable-bound] "TypeVar lower bound cannot reference later type parameter `U`" +def h[T: U..object, U](t: T) -> T: + return t +``` + +## a range a named parameter cannot inhabit + +a range whose ends name a type parameter is read at its widest: whatever that parameter is, is there +room between the two ends? `T` could be `bool`, so this range is inhabited. + +```by +def f[T, R: T..int](t: T, r: R) -> R: + return r +``` + +`str` and `int` share no value, so the only `T` that leaves anything between the ends is `Never`, +and every call would be rejected from one side or the other. a declaration nothing can use is +reported where it is written. + +```by +# error: [invalid-type-variable-bound] "TypeVar bound range `T@f..int` is inhabited only by `Never`" +def f[T: str, R: T..int](t: T, r: R) -> R: + return r ``` ## both ends are required diff --git a/crates/ty_python_semantic/resources/mdtest/generics/generic_bounds.md b/crates/ty_python_semantic/resources/mdtest/generics/generic_bounds.md new file mode 100644 index 0000000000..818a454380 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/generics/generic_bounds.md @@ -0,0 +1,324 @@ +# Bounds that name another type parameter + +```toml +[environment] +python-version = "3.13" +``` + +A type parameter's bound may name a type parameter that is already in scope where the bound is +written. PEP 695 states the rule: "The bound for a type parameter may reference other type +parameters defined in the same list, but forward references are not allowed." + +## The bound holds inside the body + +`R` is bounded by `T`, so a value of type `R` is a value of type `T`. + +```py +def pick[T, R: T](t: T, r: R) -> T: + return r +``` + +The other direction does not hold: `T` is not bounded by `R`. + +```py +def wrong[T, R: T](t: T, r: R) -> R: + return t # error: [invalid-return-type] +``` + +A bound can name a parameter through a generic class, which is PEP 695's own example. The members of +`T` are the members of `Sequence[S]`, and reading one through `T` gives back `S`. + +```py +from typing import Sequence + +class Pair[S, T: Sequence[S]]: + def first(self, t: T) -> S: + return t[0] + + def count(self, t: T) -> int: + return len(t) +``` + +## A method's bound can name its class's type parameter + +`T` belongs to the class's list, which encloses the method's, so it is in scope in the method's +bound. Binding the receiver decides what it means. + +```py +class Owner[T]: + def narrow[U: T](self, u: U) -> T: + return u + +reveal_type(Owner[int]().narrow(1)) # revealed: int + +def _(owner: Owner[object]) -> None: + reveal_type(owner.narrow("a")) # revealed: object +``` + +## Explicit specialization + +The bound is checked against the argument with the arguments already chosen substituted into it, so +the type it is measured against is `Sequence[int]` rather than `Sequence[S]`. + +```py +from typing import Sequence + +class Pair[S, T: Sequence[S]]: + x: T + +def _(ok: Pair[int, list[int]]) -> None: + reveal_type(ok.x) # revealed: list[int] + +# error: [invalid-type-arguments] "Type `list[str]` is not assignable to upper bound `Sequence[int]` of type variable `T@Pair`" +def _(bad: Pair[int, list[str]]) -> None: ... +``` + +A parameter left to its default counts as chosen, so a later bound naming it sees the default. +Whether the default itself sits inside such a bound is a question about a specialization too, so it +is asked here rather than at the declaration. + +```py +from typing import Sequence + +class Defaulted[S = int, T: Sequence[S] = list[int]]: + x: T + +def _(ok: Defaulted[int, list[int]]) -> None: + reveal_type(ok.x) # revealed: list[int] + +# error: [invalid-type-arguments] "Type `list[str]` is not assignable to upper bound `Sequence[int]` of type variable `T@Defaulted`" +def _(bad: Defaulted[int, list[str]]) -> None: ... +``` + +A type alias declares its type parameters the same way. + +```py +from typing import Sequence + +type Named[S, T: Sequence[S]] = tuple[S, T] + +def _(ok: Named[int, list[int]]) -> None: + reveal_type(ok) # revealed: tuple[int, list[int]] + +# error: [invalid-type-arguments] "Type `list[str]` is not assignable to upper bound `Sequence[int]` of type variable `T@Named`" +def _(bad: Named[int, list[str]]) -> None: ... +``` + +## Explicit specialization written by name + +basedpython lets a subscript name the type parameter it fills. A bound does not stop applying +because the argument that fills it was written by name. + +```by +from typing import Sequence + +class Pair[S, T: Sequence[S]]: + x: T + +# error: [invalid-type-arguments] "Type `list[str]` is not assignable to upper bound `Sequence[int]` of type variable `T@Pair`" +def _(bad: Pair[S=int, T=list[str]]) -> None: ... +``` + +## Inference + +The bound is a relation between the two type parameters, so it takes part in solving the call rather +than being checked against one parameter at a time. `R`'s solution is a floor under `T`. + +```py +class Animal: ... +class Dog(Animal): ... + +def pick[T, R: T](t: T, r: R) -> T: + return t + +reveal_type(pick(Dog(), Dog())) # revealed: Dog +reveal_type(pick(Animal(), Dog())) # revealed: Animal +``` + +When the argument for `r` is wider than the argument for `t`, the relation is still satisfiable — +`T` widens to accommodate it. This is the same answer Java and TypeScript give, and it is the only +answer that is total: the alternative, solving `T` from `t` alone and then checking `R` against it, +would have to report an error here even though `T = Animal` satisfies every constraint. + +```py +class Animal: ... +class Dog(Animal): ... + +def pick[T, R: T](t: T, r: R) -> T: + return t + +reveal_type(pick(Dog(), Animal())) # revealed: Animal +``` + +Widening is also what lets a type parameter be found through the bound alone. Nothing but the bound +mentions `T` here, so without the relation it would have no solution at all. + +```py +def only_bound[T, R: T](r: R) -> T: + return r + +reveal_type(only_bound(1)) # revealed: Literal[1] +``` + +The relation is transitive: `Q` is bounded by `R`, which is bounded by `T`, so the one argument here +reaches `T` through two hops. + +```py +def chain[T, R: T, Q: R](q: Q) -> T: + return q + +reveal_type(chain(1)) # revealed: Literal[1] +``` + +A ceiling at the far end of the chain reaches back down it, however many hops long it is. + +```py +def capped_chain[T: int, R: T, Q: R](q: Q) -> None: ... + +capped_chain(1) +# error: [invalid-argument-type] "Argument type `Literal["s"]` does not satisfy upper bound `int` of type variable `Q`" +capped_chain("s") +``` + +A named parameter that is constrained rather than bounded caps the one that names it at the union of +its constraints. + +```py +def constrained[T: (int, str), R: T](r: R) -> None: ... + +constrained(1) +# error: [invalid-argument-type] "Argument type `float` does not satisfy upper bound `int | str` of type variable `R`" +constrained(1.5) +``` + +## An argument that cannot satisfy the relation + +Widening is only available while nothing else pins the type parameter. An invariant occurrence pins +it, and then the relation is decided rather than accommodated. + +```py +def invariant[T, R: T](t: list[T], r: R) -> None: ... + +invariant([1], 2) +# error: [invalid-argument-type] "Argument type `Literal["s"]` does not satisfy upper bound `int` of type variable `R`" +invariant([1], "s") +``` + +A ceiling on the parameter that is named puts the same ceiling on the parameter that names it. + +```py +def capped[T: int, R: T](r: R) -> None: ... + +capped(3) +# error: [invalid-argument-type] "Argument type `Literal["s"]` does not satisfy upper bound `int` of type variable `R`" +capped("s") +``` + +Several arguments can fill the same type parameter, and the one reported is the one that is actually +outside the bound. + +```py +def two[T, R: T](t: list[T], first: R, second: R) -> None: ... + +# error: [invalid-argument-type] "Argument type `Literal["s"]` does not satisfy upper bound `int` of type variable `R`" +two([1], 2, "s") +``` + +## Names a bound may not use + +A parameter is not in scope inside its own bound: there is nothing for the reference to resolve to. + +```py +# error: [invalid-type-variable-bound] "TypeVar upper bound cannot reference the type parameter it bounds" +def f[T: list[T]](x: T) -> T: + return x +``` + +A later parameter is not in scope yet. + +```py +# error: [invalid-type-variable-bound] "TypeVar upper bound cannot reference later type parameter `T`" +def g[S: T, T](s: S, t: T) -> None: ... +``` + +A legacy `TypeVar` is declared by an assignment, so it holds no position in a list for the bound to +be after — and one `TypeVar` object can be reused by two unrelated generics, so naming it in a bound +names nothing in particular. + +```py +from typing import TypeVar + +S = TypeVar("S") + +def h[T: S](x: T) -> T: # error: [invalid-type-variable-bound] "TypeVar upper bound cannot be generic" + return x +``` + +Only a method's own list may name its class's. A nested class or a nested function declares a list +that nothing substitutes into — the enclosing parameter would still be standing there at every use +of the generic — so those bounds are rejected where they are written and the generic stays usable. + +```py +class Outer[T]: + # error: [invalid-type-variable-bound] "TypeVar upper bound cannot be generic" + class Inner[U: T]: ... + +def _(inner: Outer.Inner[int]) -> None: ... +def outer[T](t: T) -> None: + # error: [invalid-type-variable-bound] "TypeVar upper bound cannot be generic" + def inner[U: T](u: U) -> U: + return u + + inner(1) +``` + +Constraints are not bounds. A constrained type parameter takes its solution *from* its constraint +set, and a type parameter does not name a type to take. + +```py +# error: [invalid-type-variable-constraints] "TypeVar constraint cannot be generic" +def i[S, T: (S, int)](s: S, t: T) -> None: ... +``` + +## A rejected bound is discarded, not merely reported + +Two parameters that bound each other have no grounding, and several parts of the type system reduce +a type parameter to its bound by recursion. Reporting a cycle without also dropping it would run +that recursion forever, so a rejected bound leaves the parameter unbounded. + +```py +# error: [invalid-type-variable-bound] "TypeVar upper bound cannot reference later type parameter `R`" +def mutual[T: R, R: T](t: T, r: R) -> None: + reveal_type(t) # revealed: T@mutual + reveal_type(r) # revealed: R@mutual + +mutual(1, "a") +``` + +## `Self` is not one of these names + +`Self` is bound by the enclosing class rather than by the list being declared, so a bound naming it +is substituted when the method binds its receiver, and a bound naming both is fine. + +```py +from typing import Self + +class C: + def clone[T: Self](self, other: T) -> T: + return other + +class Sub(C): ... + +def _(s: Sub) -> None: + reveal_type(s.clone(s)) # revealed: Sub +``` + +## A variadic pack's bound + +A pack's bound describes its members rather than the pack's own value, so it is checked member by +member and has nowhere to record a relation between two parameters. + +```by +# error: [invalid-type-variable-bound] "A variadic pack's bound cannot be generic" +def pack[T, *Ts: T](t: T, *ts: *Ts) -> None: ... +``` diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md index 429307914a..43fc9057be 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/aliases.md @@ -330,7 +330,8 @@ def _(p: P) -> None: ## Recursive TypeVarTuple alias defaults Recursive aliases that extend a `TypeVarTuple` specialization must not recursively expand while -checking their defaults. +checking their defaults. The pack a default names is the alias's own, bound by the alias, so what is +reported is the default's shape rather than an unresolvable name. ```toml [environment] @@ -339,15 +340,12 @@ python-version = "3.13" ```py # error: [invalid-legacy-type-variable] -# error: [invalid-type-form] type Nested[*Ts = Nested[*Ts]] = tuple[Nested[*Ts, Nested[*Ts]]] # error: [invalid-legacy-type-variable] -# error: [invalid-type-form] type Suffix[*Ts = Suffix[*Ts]] = list[Suffix[*Ts, int]] # error: [invalid-legacy-type-variable] -# error: [invalid-type-form] type Prefix[*Ts = Prefix[*Ts]] = tuple[Prefix[int, *Ts]] type ValidDefault[*Ts = *tuple[ValidDefault[int]]] = tuple[ValidDefault[*Ts, int]] diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md index cc8ce7bbf2..ec4595ede7 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md @@ -1095,19 +1095,21 @@ def protocol_case(x: GenericProtocol[[int], str]) -> None: ## Scoping of typevars -### No back-references +### No forward references -Typevar bounds/constraints/defaults are lazy, but cannot refer to later typevars. Furthermore, -bounds/constraints cannot refer to other type variables, i.e. they must be non-generic. +Typevar bounds, constraints and defaults are lazy, so they may name a typevar that precedes them in +the list. They may not name a later one, which is not yet in scope. Constraints may not name a +typevar at all: a constrained typevar takes its solution _from_ its constraint set, and a variable +does not name a type to take. ```py # error: [invalid-type-variable-bound] class C[S: T, T]: pass -# error: [invalid-type-variable-bound] +# `S` precedes `T`, so `T`'s bound can name it class D[S, T: S]: pass diff --git a/crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md b/crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md index 21be4e274b..03ee747045 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/pep695/variables.md @@ -1098,16 +1098,15 @@ def constrained[T: (int, str)](x: T): ### Bounds and constraints -A typevar's bounds and constraints cannot be generic, cyclic or otherwise: +A bound may name a typevar that precedes it, but not itself — a typevar is not in scope inside its +own bound, so nothing would ground the recursion. Constraints may not name a typevar at all. ```py from typing import Any -# error: [invalid-type-variable-bound] def f[S, T: list[S]](x: S, y: T) -> S | T: return x or y -# error: [invalid-type-variable-bound] class C[S, T: list[S]]: x: S y: T diff --git a/crates/ty_python_semantic/resources/mdtest/generics/typeddict_and_self_bounds.md b/crates/ty_python_semantic/resources/mdtest/generics/typeddict_and_self_bounds.md index cabb8fe8c5..97348fe329 100644 --- a/crates/ty_python_semantic/resources/mdtest/generics/typeddict_and_self_bounds.md +++ b/crates/ty_python_semantic/resources/mdtest/generics/typeddict_and_self_bounds.md @@ -362,7 +362,12 @@ def _(s: Sub) -> None: reveal_type(s.clone()) # revealed: Sub ``` -### Other type variables are still rejected as a bound +### A legacy type variable is still rejected as a bound + +A legacy `TypeVar` is declared by an assignment rather than by a type-parameter list, so there is no +position for it to precede the parameter it bounds — and one `TypeVar` object can be reused by two +unrelated generics, so it names nothing in particular here. A PEP 695 parameter that precedes the +one being bounded is fine. ```py from typing import TypeVar @@ -373,7 +378,6 @@ S = TypeVar("S") def f[T: S](x: T) -> T: return x -# error: [invalid-type-variable-bound] "TypeVar upper bound cannot be generic" def g[U, T: U](x: T) -> T: return x ``` diff --git "a/crates/ty_python_semantic/resources/mdtest/snapshots/classes.md_-_Generic_classes___PEP\342\200\246_-_Scoping_of_typevars_-_No_back-references_(9051beb16a623d36).snap" "b/crates/ty_python_semantic/resources/mdtest/snapshots/classes.md_-_Generic_classes___PEP\342\200\246_-_Scoping_of_typevars_-_No_forward_reference\342\200\246_(f4cd31f00230338a).snap" similarity index 84% rename from "crates/ty_python_semantic/resources/mdtest/snapshots/classes.md_-_Generic_classes___PEP\342\200\246_-_Scoping_of_typevars_-_No_back-references_(9051beb16a623d36).snap" rename to "crates/ty_python_semantic/resources/mdtest/snapshots/classes.md_-_Generic_classes___PEP\342\200\246_-_Scoping_of_typevars_-_No_forward_reference\342\200\246_(f4cd31f00230338a).snap" index a3573d2ea0..3a0b04fd40 100644 --- "a/crates/ty_python_semantic/resources/mdtest/snapshots/classes.md_-_Generic_classes___PEP\342\200\246_-_Scoping_of_typevars_-_No_back-references_(9051beb16a623d36).snap" +++ "b/crates/ty_python_semantic/resources/mdtest/snapshots/classes.md_-_Generic_classes___PEP\342\200\246_-_Scoping_of_typevars_-_No_forward_reference\342\200\246_(f4cd31f00230338a).snap" @@ -4,7 +4,7 @@ expression: snapshot --- --- -mdtest name: classes.md - Generic classes: PEP 695 syntax - Scoping of typevars - No back-references +mdtest name: classes.md - Generic classes: PEP 695 syntax - Scoping of typevars - No forward references mdtest path: crates/ty_python_semantic/resources/mdtest/generics/pep695/classes.md --- @@ -17,7 +17,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/generics/pep695/classes. 2 | class C[S: T, T]: 3 | pass 4 | - 5 | # error: [invalid-type-variable-bound] + 5 | # `S` precedes `T`, so `T`'s bound can name it 6 | class D[S, T: S]: 7 | pass 8 | @@ -42,7 +42,7 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/generics/pep695/classes. # Diagnostics ``` -error[invalid-type-variable-bound]: TypeVar upper bound cannot be generic +error[invalid-type-variable-bound]: TypeVar upper bound cannot reference later type parameter `T` --> src/mdtest_snippet.py:2:12 | 2 | class C[S: T, T]: @@ -50,15 +50,6 @@ error[invalid-type-variable-bound]: TypeVar upper bound cannot be generic ``` -``` -error[invalid-type-variable-bound]: TypeVar upper bound cannot be generic - --> src/mdtest_snippet.py:6:15 - | -6 | class D[S, T: S]: - | ^ - -``` - ``` error[invalid-type-variable-constraints]: TypeVar constraint cannot be generic --> src/mdtest_snippet.py:10:18 diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 3cea501ce3..61b8c46709 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -12010,7 +12010,15 @@ impl<'db> TypeMapping<'_, 'db> { Some(_) => false, // Specialized to a concrete type, filter out } }); - if specialization.specialize_self_domain() { + // a retained variable is rewritten when the mapping reaches inside it — a + // `Self` domain, or a bound naming a type parameter the specialization names. + // the list has to carry the same variable the parameters and return type do, or + // the two halves of the signature disagree about what it is bounded by + if specialization.specialize_self_domain() + || kept + .clone() + .any(|bound_typevar| bound_typevar.typevar(db).bound_mentions_typevars(db)) + { let kept = kept.filter_map(|bound_typevar| { Type::TypeVar(bound_typevar) .apply_type_mapping( diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 5b2a1947d1..5da9a8b97b 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -53,7 +53,8 @@ use crate::types::function::{ OverloadLiteral, }; use crate::types::generics::{ - GenericContext, Specialization, SpecializationBuilder, SpecializationError, TypeVarInference, + ApplySpecialization, GenericContext, Specialization, SpecializationBuilder, + SpecializationError, TypeVarInference, }; use crate::types::infer::original_class_type; use crate::types::known_instance::{FieldInstance, InternedConstraintSetSolution}; @@ -6627,7 +6628,7 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { // literal values are kept unpromoted. if self.call_expression_tcx.preserve_literals && let Some(lower) = bounds.evidence_lower() - && crate::types::visitor::any_over_type(self.db, self.env, lower, false, |ty| { + && any_over_type(self.db, self.env, lower, false, |ty| { ty.as_literal_value().is_some() }) { @@ -6728,11 +6729,99 @@ impl<'a, 'db> ArgumentTypeChecker<'a, 'db> { inference, &self.typevars_reached_by_arguments, ); + self.check_declared_typevar_relations(generic_context, specialization); self.return_ty = self.return_ty.apply_specialization(db, specialization); self.inference = Some(inference); } + /// Reports an argument that puts a type variable outside a bound naming another type + /// variable. + /// + /// Such a bound is a relation between two variables, so whether it holds is only decided once + /// both are solved — `def f[T, R: T](t: list[T], r: R)` pins `T` through the invariant + /// `list[T]`, and whether `r` fits is a question about the pair, not about either argument on + /// its own. The relation is conjoined into the constraint set, where an unsatisfiable one + /// simply yields no solution rather than an error, so the report is made here against the + /// argument that supplied the type variable at fault. + fn check_declared_typevar_relations( + &mut self, + generic_context: GenericContext<'db>, + specialization: Specialization<'db>, + ) { + let db = self.db; + let env = self.env; + for bound_typevar in generic_context.variables(db) { + let typevar = bound_typevar.typevar(db); + // a pack's bound may not name a type parameter, so `pack_bound` has already + // discarded it and only an ordinary bound reaches here + if !typevar.bound_mentions_typevars(db) { + continue; + } + let solved = Type::TypeVar(bound_typevar).apply_specialization(db, specialization); + let upper = match typevar.bound_or_constraints(db, env) { + Some(TypeVarBoundOrConstraints::UpperBound(bound)) => Some((bound, false)), + _ => None, + }; + // basedpython: a bound range's lower end is a second bound, and it is checked the + // same way with the two sides swapped + let lower = typevar.lower_bound(db).map(|lower| (lower, true)); + for (declared, is_lower) in upper.into_iter().chain(lower) { + let bound = declared.apply_specialization(db, specialization); + // when the named type variable had no solution of its own the substitution + // collapses to `Never`, which names nothing the reader wrote. fall back to what + // the declarations say — `R: T` where `T: int` reads as `int` + let bound = if bound.is_never() || bound.has_typevar(db, env) { + declared_bound_ceiling(db, env, generic_context, declared) + } else { + bound + }; + let (source, target) = if is_lower { + (bound, solved) + } else { + (solved, bound) + }; + if source.is_assignable_to(db, env, target) { + continue; + } + // blame the argument that actually falls outside the bound. several arguments + // can fill the same type variable, and the ones that fit are not at fault + let mut blamed = None; + for relation in self.argument_relations() { + if !any_over_type(db, env, relation.declared_type, false, |ty| { + ty == Type::TypeVar(bound_typevar) + }) { + continue; + } + let candidate = (relation.adjusted_argument_index, relation.argument_type); + let outside = if is_lower { + !bound.is_assignable_to(db, env, relation.argument_type) + } else { + !relation.argument_type.is_assignable_to(db, env, bound) + }; + if outside { + blamed = Some(candidate); + break; + } + blamed.get_or_insert(candidate); + } + // a type variable can be solved without any argument mentioning it — through the + // relation alone, or from the type context — and then the solution is all there + // is to report + let (argument_index, argument) = blamed.unwrap_or((None, solved)); + self.errors.push(BindingError::SpecializationError { + error: SpecializationError::MismatchedRelation { + bound_typevar, + argument, + bound, + is_lower, + }, + argument_index, + }); + } + } + } + /// Infers a variadic type variable tuple from every argument matched to `*args`. /// /// Comparing the complete argument tuple with the declared tuple preserves fixed elements, @@ -8176,6 +8265,34 @@ fn call_specialization<'db>( }) } +/// What a bound naming other type parameters says when only the declarations are known. +/// +/// A relation like `R: T` is reported against the solved `T`, but when `T` had no solution of its +/// own the substitution collapses to `Never`, which names nothing anybody wrote. Replacing each +/// named parameter with its own declared ceiling recovers something the reader can act on: +/// `def f[T: int, R: T]` reads as `int`. +fn declared_bound_ceiling<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + generic_context: GenericContext<'db>, + bound: Type<'db>, +) -> Type<'db> { + let ceilings: Box<[Type<'db>]> = generic_context + .variables(db) + .map(|variable| variable.typevar(db).declared_ceiling(db, env)) + .collect(); + bound.apply_type_mapping( + db, + env, + &TypeMapping::ApplySpecialization(ApplySpecialization::Partial { + generic_context, + types: &ceilings, + skip: None, + }), + TypeContext::default(), + ) +} + /// Binding information for one of the overloads of a callable. #[derive(Debug, Clone)] pub(crate) struct Binding<'db> { @@ -10574,6 +10691,20 @@ impl<'db> BindingError<'db> { )); } } + SpecializationError::MismatchedRelation { + bound_typevar, + bound, + is_lower, + .. + } => { + let typevar_name = bound_typevar.typevar(context.db()).name(context.db()); + let bound = bound.display(context.db(), env); + let end = if *is_lower { "lower" } else { "upper" }; + diag.set_primary_annotation_message(format_args!( + "Argument type `{argument_ty_display}` does not satisfy \ + {end} bound `{bound}` of type variable `{typevar_name}`" + )); + } SpecializationError::UnsatisfiedPackBound { bound_typevar, violation, diff --git a/crates/ty_python_semantic/src/types/constraints.rs b/crates/ty_python_semantic/src/types/constraints.rs index a97855a9dd..6b310d1961 100644 --- a/crates/ty_python_semantic/src/types/constraints.rs +++ b/crates/ty_python_semantic/src/types/constraints.rs @@ -480,6 +480,32 @@ impl<'db, 'c> ConstraintSet<'db, 'c> { Self::from_node(builder, node, source_order) } + /// Returns a constraint set that holds a typevar inside the bounds its own declaration gives + /// it. + /// + /// These are *validity* bounds, not inference evidence: nothing was observed at a call site, + /// the declaration simply rules out every solution outside the range. Recording them as + /// evidence would make a typevar look inferred-from on paths that never mention it, and + /// whether a typevar has lower evidence is what selects the branch in + /// [`PathBounds::preliminary_solve`] and gates [`PathBound::restrict_gradual_solution`]. + pub(crate) fn constrain_typevar_declared_bounds( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + builder: &'c ConstraintSetBuilder<'db>, + typevar: BoundTypeVarInstance<'db>, + lower: Option>, + upper: Option>, + ) -> Self { + Self::constrain_typevar_with_bounds( + db, + env, + builder, + typevar, + lower.map(ConstraintBound::Validity), + upper.map(ConstraintBound::Validity), + ) + } + /// Returns a constraint set that constrains a typevar to be a supertype of `lower`. pub(crate) fn constrain_typevar_lower_bound( db: &'db dyn Db, @@ -4470,11 +4496,21 @@ impl<'db> PathBounds<'db> { .require_bound_or_constraints(db, env) { TypeVarBoundOrConstraints::UpperBound(bound) => { - let declared_upper = bound.top_materialization(db, env); + // a bound naming another type variable is already conjoined into this constraint + // set, so there is nothing left for it to cap here — and top-materializing it + // would put a type variable where a concrete ceiling is expected + let declared_upper = if bound_typevar.typevar(db).bound_mentions_typevars(db) { + Type::object() + } else { + bound.top_materialization(db, env) + }; // basedpython: a declared lower bound raises the floor of every solution. The // narrowest type above both it and any inferred lower bound is their union - let declared_lower = bound_typevar.typevar(db).lower_bound(db); + let declared_lower = bound_typevar + .typevar(db) + .lower_bound(db) + .filter(|_| !bound_typevar.typevar(db).bound_mentions_typevars(db)); let lower = match declared_lower { Some(declared) if path_bound.evidence_lower.is_some() => { UnionType::from_two_elements(db, env, lower, declared) diff --git a/crates/ty_python_semantic/src/types/generics.rs b/crates/ty_python_semantic/src/types/generics.rs index 868f34f3cb..7865ac31bc 100644 --- a/crates/ty_python_semantic/src/types/generics.rs +++ b/crates/ty_python_semantic/src/types/generics.rs @@ -209,6 +209,29 @@ fn find_typevar_binding<'db>( } continue; } + // A class's or alias's own type-parameter scope binds the parameters declared in it, the + // same way a function's does. Reaching them through the generic context instead does not + // work from inside that scope: the context is what is being built, and a bound naming an + // earlier parameter (`class Pair[S, T: Sequence[S]]`) is inferred while it is still + // incomplete, which would leave `S` unbound. + if let NodeWithScopeKind::ClassTypeParameters(class) = ancestor_scope.node() + && !crossed_class_scope + && typevar + .definition(db) + .is_some_and(|definition| definition.file_scope(db) == ancestor_scope_id) + { + let definition = index.expect_single_definition(class); + return Some(typevar.with_binding_context(db, definition)); + } + if let NodeWithScopeKind::TypeAliasTypeParameters(type_alias) = ancestor_scope.node() + && !crossed_class_scope + && typevar + .definition(db) + .is_some_and(|definition| definition.file_scope(db) == ancestor_scope_id) + { + let definition = index.expect_single_definition(type_alias); + return Some(typevar.with_binding_context(db, definition)); + } // basedpython: a match type's `case` captures are type variables defined in the // alias's own value scope — not in its type-parameter list — so the alias itself is // what binds them @@ -1252,6 +1275,48 @@ impl<'db> GenericContext<'db> { expanded.into_boxed_slice() } + /// The types bound to the type parameters that precede the one at `provided.len()`, with any + /// left to its default filled in. + /// + /// A type parameter's bound may name a parameter that precedes it (`class Pair[S, T: + /// Sequence[S]]`), so checking an argument against that bound means substituting the + /// arguments already chosen into it first. Those are exactly this prefix: a bound naming a + /// parameter later in the list is rejected where it is written, so nothing beyond it is + /// needed. + /// + /// This is [`Self::fill_in_defaults`] restricted to a prefix, and fills a defaulted slot the + /// same way — by substituting the slots before it into the default. + pub(crate) fn provided_prefix( + self, + db: &'db dyn Db, + provided: &[Option>], + ) -> Box<[Type<'db>]> { + let env = ProgramEnvironment::from_program(self.program(db)); + let mut prefix: Vec> = Vec::with_capacity(provided.len()); + for (idx, (provided, typevar)) in provided.iter().zip(self.variables(db)).enumerate() { + let filled = match provided { + Some(ty) => *ty, + None => typevar + .default_type(db) + .map(|default| { + default.apply_type_mapping( + db, + &env, + &TypeMapping::ApplySpecialization(ApplySpecialization::Partial { + generic_context: self, + types: &prefix[0..idx], + skip: None, + }), + TypeContext::default(), + ) + }) + .unwrap_or_else(Type::unknown), + }; + prefix.push(filled); + } + prefix.into_boxed_slice() + } + /// Creates a specialization of this generic context. Panics if the length of `types` does not /// match the number of typevars in the generic context. If any provided type is `None`, we /// will use the corresponding typevar's default type. @@ -3138,6 +3203,50 @@ fn relation_directions( .flatten() } +/// The relations a generic context's own declarations impose between its type variables. +/// +/// A bound that names another type parameter — `def f[T, R: T]` — says `R <= T`, which is a +/// relation between two variables rather than a ceiling made of concrete types. The constraint +/// set already represents such a relation, and solving it there is what lets `T` be found from +/// `R` when nothing else mentions `T`, and what makes the relation hold transitively. So it is +/// conjoined once, here, and every path that treats a declared bound as a concrete type leaves a +/// generic one alone. +/// +/// A bound naming only `Self` is not one of these: `Self` is bound by the receiver rather than by +/// the context being solved, and has its own substitution. +fn declared_bound_constraints<'db, 'c>( + db: &'db dyn Db, + env: &'c ProgramEnvironment<'db>, + constraints: &'c ConstraintSetBuilder<'db>, + generic_context: GenericContext<'db>, +) -> ConstraintSet<'db, 'c> { + let mut declared = ConstraintSet::from_bool(constraints, true); + for bound_typevar in generic_context.variables(db) { + let typevar = bound_typevar.typevar(db); + // a pack's bound is not an interval on the pack's own value and may not name a type + // parameter at all, so `pack_bound` has already discarded it and this is only reached for + // an ordinary bound + if !typevar.bound_mentions_typevars(db) { + continue; + } + let upper = match typevar.bound_or_constraints(db, env) { + Some(TypeVarBoundOrConstraints::UpperBound(bound)) => Some(bound), + _ => None, + }; + let lower = typevar.lower_bound(db); + let bounds = ConstraintSet::constrain_typevar_declared_bounds( + db, + env, + constraints, + bound_typevar, + lower, + upper, + ); + declared.intersect(db, constraints, bounds); + } + declared +} + impl<'db, 'c> SpecializationBuilder<'db, 'c> { pub(crate) fn new( db: &'db dyn Db, @@ -3151,7 +3260,7 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { constraints, generic_context, inferable: generic_context.inferable_typevars(db), - pending: ConstraintSet::from_bool(constraints, true), + pending: declared_bound_constraints(db, env, constraints, generic_context), types: LegacyTypeMappings::Available(FxHashMap::default()), unconstrained: FxHashSet::default(), paramspec_seen: FxHashSet::default(), @@ -3985,6 +4094,12 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { } else { ConstraintFailureVariance::Contravariant }; + // a bound naming another type variable is a relation in the constraint set, and an + // unsatisfied relation between two variables is not a violation of either one's + // declaration — the path is simply not a solution + if bound_typevar.typevar(db).bound_mentions_typevars(db) { + return None; + } let error = match bound_typevar .typevar(db) .bound_or_constraints(db, self.env)? @@ -4672,6 +4787,17 @@ impl<'db, 'c> SpecializationBuilder<'db, 'c> { self.add_type_mapping(bound_typevar, ty, polarity); return Ok(()); } + // a bound naming another type variable is conjoined into the constraint set + // instead: checking it here would compare an argument against a variable that is + // still being solved, and intersecting it into the mapping below would leave that + // variable inside this one's solution + if bound_typevar + .typevar(self.db) + .bound_mentions_typevars(self.db) + { + self.add_type_mapping(bound_typevar, ty, polarity); + return Ok(()); + } match bound_typevar .typevar(self.db) .bound_or_constraints(self.db, env) @@ -5239,6 +5365,17 @@ pub(crate) enum SpecializationError<'db> { argument: Type<'db>, violation: PackBoundViolation<'db>, }, + /// A bound that names another type parameter (`def f[T, R: T]`) is a relation between two + /// variables, so it is only decided once both are solved. It carries the bound with the + /// solved specialization already substituted in, because the bound as written names a type + /// variable and would tell the reader nothing. + MismatchedRelation { + bound_typevar: BoundTypeVarInstance<'db>, + argument: Type<'db>, + bound: Type<'db>, + /// Whether `bound` is the lower end of a basedpython bound range rather than the upper. + is_lower: bool, + }, } impl<'db> SpecializationError<'db> { @@ -5246,7 +5383,8 @@ impl<'db> SpecializationError<'db> { match self { Self::MismatchedBound { bound_typevar, .. } | Self::MismatchedConstraint { bound_typevar, .. } - | Self::UnsatisfiedPackBound { bound_typevar, .. } => *bound_typevar, + | Self::UnsatisfiedPackBound { bound_typevar, .. } + | Self::MismatchedRelation { bound_typevar, .. } => *bound_typevar, } } @@ -5254,7 +5392,8 @@ impl<'db> SpecializationError<'db> { match self { Self::MismatchedBound { argument, .. } | Self::MismatchedConstraint { argument, .. } - | Self::UnsatisfiedPackBound { argument, .. } => *argument, + | Self::UnsatisfiedPackBound { argument, .. } + | Self::MismatchedRelation { argument, .. } => *argument, } } } diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 30b163c73d..e9e6d3bbb8 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -1797,7 +1797,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.infer_class_deferred(definition, class.node(self.module())); } DefinitionKind::TypeVar(typevar) => { - self.infer_typevar_deferred(typevar.node(self.module())); + self.infer_typevar_deferred(definition, typevar.node(self.module())); } DefinitionKind::ParamSpec(paramspec) => { self.infer_paramspec_deferred(paramspec.node(self.module())); @@ -4813,7 +4813,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { None }; if let Some(bound) = arguments.find_keyword("bound") { - let bound_type = self.infer_type_variable_bound(&bound.value); + let bound_type = self.infer_type_variable_bound(&bound.value, None); bound_or_constraints = Some(TypeVarBoundOrConstraints::UpperBound(bound_type)); } if let Some(default) = arguments.find_keyword("default") { diff --git a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs index 3bac5f9259..ec2f58c6bb 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/subscript.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/subscript.rs @@ -37,11 +37,11 @@ use crate::types::typed_dict::{ use crate::types::typevar::pack_bound_violation; use crate::types::typevar::{BindingContext, TypeVarSet}; use crate::types::{ - BoundTypeVarInstance, CallArguments, CallDunderError, CallableBinding, CycleDetector, - DisplaySettings, DynamicType, InternedType, KnownClass, KnownInstanceType, LintDiagnosticGuard, - MemberLookupPolicy, Parameter, Parameters, SpecialFormType, StaticClassLiteral, Type, - TypeAliasType, TypeAndQualifiers, TypeContext, TypeMapping, TypeVarBoundOrConstraints, - UnionType, UnionTypeInstance, any_over_type, todo_type, + ApplySpecialization, BoundTypeVarInstance, CallArguments, CallDunderError, CallableBinding, + CycleDetector, DisplaySettings, DynamicType, InternedType, KnownClass, KnownInstanceType, + LintDiagnosticGuard, MemberLookupPolicy, Parameter, Parameters, SpecialFormType, + StaticClassLiteral, Type, TypeAliasType, TypeAndQualifiers, TypeContext, TypeMapping, + TypeVarBoundOrConstraints, UnionType, UnionTypeInstance, any_over_type, todo_type, }; use crate::{Db, FxOrderSet, ProgramEnvironment}; use ty_python_core::definition::Definition; @@ -107,6 +107,42 @@ fn add_typevar_definition<'db>( ); } +/// The type arguments already bound to the type parameters that precede the one being checked. +/// +/// A type parameter's bound may name one that precedes it, so it has to be read with those +/// arguments substituted in. Both specialization pipelines build their argument list in +/// declaration order, so the prefix is whatever they have accumulated when the check runs. +#[derive(Clone, Copy)] +struct TypeArgumentPrefix<'a, 'db> { + generic_context: GenericContext<'db>, + provided: &'a [Option>], +} + +impl<'db> TypeArgumentPrefix<'_, 'db> { + fn substitute( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Option>, + ) -> Option> { + let ty = ty?; + if !ty.has_typevar(db, env) { + return Some(ty); + } + let types = self.generic_context.provided_prefix(db, self.provided); + Some(ty.apply_type_mapping( + db, + env, + &TypeMapping::ApplySpecialization(ApplySpecialization::Partial { + generic_context: self.generic_context, + types: &types, + skip: None, + }), + TypeContext::default(), + )) + } +} + enum ExplicitSpecializationError { InvalidParamSpec, ParamSpecForTypeVar, @@ -164,6 +200,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { typevar: BoundTypeVarInstance<'db>, provided_type: Type<'db>, node: impl Ranged + Copy, + prefix: TypeArgumentPrefix<'_, 'db>, ) -> Option { let env = self.program_environment(); let db = self.db(); @@ -173,8 +210,21 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // against bounds/constraints, but recording the expression for deferred // checking at end of scope. This would avoid a lot of cycles caused by eagerly // doing assignment checks here. - let lower_bound = typevar.typevar(db).lower_bound(db); - let bound_or_constraints = typevar.typevar(db).bound_or_constraints(db, env); + // a bound may name a type parameter that precedes this one, and what it means here is + // the argument that filled that parameter — not the parameter itself + let lower_bound = prefix.substitute(db, env, typevar.typevar(db).lower_bound(db)); + let bound_or_constraints = + typevar + .typevar(db) + .bound_or_constraints(db, env) + .map(|bound_or_constraints| match bound_or_constraints { + TypeVarBoundOrConstraints::UpperBound(bound) => { + TypeVarBoundOrConstraints::UpperBound( + prefix.substitute(db, env, Some(bound)).unwrap_or(bound), + ) + } + constraints @ TypeVarBoundOrConstraints::Constraints(_) => constraints, + }); let provided_type = if lower_bound.is_some() || bound_or_constraints.is_some() { // Defaults such as `Box[T]` may be inferred before `T` has a binding context. // Bind only the copy used for validation, so the original default can later @@ -1253,7 +1303,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { KeywordSlot::Bound(expr) => { let provided_type = self.infer_type_expression(expr); if self - .check_type_argument_bounds(*typevar, provided_type, *expr) + .check_type_argument_bounds( + *typevar, + provided_type, + *expr, + TypeArgumentPrefix { + generic_context, + provided: &specialization_types, + }, + ) .is_some() { specialization_types.push(Some(Type::unknown())); @@ -1693,9 +1751,15 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { continue; } - if let Some(failure) = - self.check_type_argument_bounds(typevar, provided_type, type_argument.node) - { + if let Some(failure) = self.check_type_argument_bounds( + typevar, + provided_type, + type_argument.node, + TypeArgumentPrefix { + generic_context, + provided: &specialization_types, + }, + ) { error = Some(failure); specialization_types.push(Some(Type::unknown())); } else { diff --git a/crates/ty_python_semantic/src/types/infer/builder/typevar.rs b/crates/ty_python_semantic/src/types/infer/builder/typevar.rs index 798e428d92..638503c04e 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/typevar.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/typevar.rs @@ -16,8 +16,9 @@ use crate::{ original_class_type, }, typevar::{ - TypeVarBoundOrConstraintsEvaluation, TypeVarConstraints, TypeVarDefaultEvaluation, - TypeVarIdentity, TypeVarInstance, TypeVarLowerBoundEvaluation, + BoundScopeViolation, DeclaredEnd, TypeVarBoundOrConstraintsEvaluation, + TypeVarConstraints, TypeVarDefaultEvaluation, TypeVarIdentity, TypeVarInstance, + TypeVarLowerBoundEvaluation, bound_scope_violation_for, }, visitor::find_over_type, }, @@ -60,6 +61,18 @@ fn constraint_set_nodes( } } +/// basedpython: why a bound range `Lower..Upper` admits nothing worth writing. +/// +/// Either end may name a type parameter, so the range is judged against what those parameters +/// could be rather than against the names themselves. +enum RangeVerdict<'db> { + /// No specialization puts anything in the range, the ends read at their widest. + Uninhabited { lower: Type<'db>, upper: Type<'db> }, + /// Something is in the range, but only when the named parameter is `Never` — every call + /// would then be rejected from one side or the other. + OnlyNever { lower: Type<'db>, upper: Type<'db> }, +} + impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { pub(super) fn is_basedpython_file(&self) -> bool { self.source_type().is_basedpython() @@ -186,7 +199,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ); } - pub(super) fn infer_typevar_deferred(&mut self, node: &'ast ast::TypeParamTypeVar) { + pub(super) fn infer_typevar_deferred( + &mut self, + definition: Definition<'db>, + node: &'ast ast::TypeParamTypeVar, + ) { let env = self.program_environment(); let ast::TypeParamTypeVar { range: _, @@ -283,7 +300,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { Some(TypeVarBoundOrConstraints::UpperBound(bound_ty)) } Some(expr) => { - let bound_ty = self.infer_type_variable_bound(expr); + let bound_ty = self.infer_type_variable_bound(expr, Some(definition)); Some(TypeVarBoundOrConstraints::UpperBound(bound_ty)) } None => None, @@ -292,21 +309,74 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // basedpython: the lower end of a bound range is always inferred, even when the range was // rejected above, so that every expression in the definition region has a type let lower_bound_ty = lower_bound.as_deref().map(|lower_expr| { - let lower_ty = self.infer_type_variable_bound_end(lower_expr, "lower"); - if let Some(TypeVarBoundOrConstraints::UpperBound(upper_ty)) = bound_or_constraints - && !lower_ty.is_assignable_to(db, env, upper_ty) + let lower_ty = + self.infer_type_variable_bound_end(lower_expr, "lower", Some(definition)); + // Either end may name another type parameter, and at the declaration nothing says + // what that parameter is. Two readings answer two different questions. + // + // Widest — the lower end at its floor, the upper end at its ceiling — asks whether + // *any* specialization makes the range inhabited. `def f[T, R: T..int]` passes: `T` + // could be `bool`. + // + // Narrowest for the lower end — at its ceiling — asks whether any specialization + // leaves room for something other than `Never`. `def f[T: str, R: T..int]` fails + // that: `str` and `int` share no value, so only `T = Never` inhabits the range and + // every call of `f` is rejected from one side or the other. A declaration nothing can + // use is reported where it is written. + let range_verdict = match bound_or_constraints { + Some(TypeVarBoundOrConstraints::UpperBound(upper_ty)) => { + let upper_widest = + upper_ty.with_typevars_at_declared_end(db, env, DeclaredEnd::Ceiling); + let lower_widest = + lower_ty.with_typevars_at_declared_end(db, env, DeclaredEnd::Floor); + if !lower_widest.is_assignable_to(db, env, upper_widest) { + Some(RangeVerdict::Uninhabited { + lower: lower_widest, + upper: upper_widest, + }) + } else if lower_ty + .with_typevars_at_declared_end(db, env, DeclaredEnd::Ceiling) + .is_disjoint_from(db, env, upper_widest) + { + Some(RangeVerdict::OnlyNever { + lower: lower_ty, + upper: upper_ty, + }) + } else { + None + } + } + _ => None, + }; + if let Some(verdict) = range_verdict && let Some(builder) = self .context .report_lint(&INVALID_TYPE_VARIABLE_BOUND, lower_expr) { - let mut diagnostic = builder.into_diagnostic(format_args!( - "TypeVar lower bound `{lower}` is not assignable to its upper bound `{upper}`", - lower = lower_ty.display(db, env), - upper = upper_ty.display(db, env), - )); - diagnostic.info( - "no type satisfies this bound range, so the type variable cannot be specialized", - ); + match verdict { + RangeVerdict::OnlyNever { lower, upper } => { + let mut diagnostic = builder.into_diagnostic(format_args!( + "TypeVar bound range `{lower}..{upper}` is inhabited only by `Never`", + lower = lower.display(db, env), + upper = upper.display(db, env), + )); + diagnostic.info(format_args!( + "no specialization of `{lower}` leaves room below `{upper}`", + lower = lower.display(db, env), + upper = upper.display(db, env), + )); + } + RangeVerdict::Uninhabited { lower, upper } => { + let mut diagnostic = builder.into_diagnostic(format_args!( + "TypeVar lower bound `{lower}` is not assignable to its upper bound `{upper}`", + lower = lower.display(db, env), + upper = upper.display(db, env), + )); + diagnostic.info( + "no type satisfies this bound range, so the type variable cannot be specialized", + ); + } + } } (lower_expr, lower_ty) }); @@ -315,7 +385,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // the default is a specialization like any other, so it has to sit above the lower // end too — `validate_typevar_default` only knows about the upper end if let Some((lower_expr, lower_ty)) = lower_bound_ty - && !lower_ty.is_assignable_to(db, env, default_ty) + && !lower_ty + .with_typevars_at_declared_end(db, env, DeclaredEnd::Floor) + .is_assignable_to(db, env, default_ty) && let Some(builder) = self .context .report_lint(&INVALID_TYPE_VARIABLE_DEFAULT, default_expr) @@ -346,17 +418,32 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { self.deferred_state = previous_deferred_state; } - /// Infer a type variable's upper bound, reporting a diagnostic if it is generic. + /// Infer a type variable's upper bound, reporting a diagnostic if it names a type variable + /// it may not name. /// - /// `Self` is exempt from that check: it is bound by the enclosing class, not by the generic - /// context being defined, so `def method[T: Self](self) -> T` leaves nothing unsolved - pub(super) fn infer_type_variable_bound(&mut self, bound: &ast::Expr) -> Type<'db> { - self.infer_type_variable_bound_end(bound, "upper") + /// `definition` is the type parameter the bound belongs to, and is `None` for a legacy + /// `TypeVar(bound=...)`, which has no type-parameter list to be positioned in. + pub(super) fn infer_type_variable_bound( + &mut self, + bound: &ast::Expr, + definition: Option>, + ) -> Type<'db> { + self.infer_type_variable_bound_end(bound, "upper", definition) } /// Infer one end of a type variable's bound, named by `end` in any diagnostic. basedpython /// bound ranges `T: Lower..Upper` have two ends; every other form has only an upper bound. - fn infer_type_variable_bound_end(&mut self, bound: &ast::Expr, end: &str) -> Type<'db> { + /// + /// A bound may name a type parameter that is already in scope where it is written — an + /// earlier entry in the same list, or one belonging to an enclosing list — so what is + /// reported here is the scope rule, not the mere presence of a type variable. The same rule + /// decides whether the bound is kept at all; see `bound_scope_violation_for`. + fn infer_type_variable_bound_end( + &mut self, + bound: &ast::Expr, + end: &str, + definition: Option>, + ) -> Type<'db> { let env = self.program_environment(); let previously_in_type_variable_bound = self .context @@ -368,12 +455,37 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { previously_in_type_variable_bound, ); - if bound_ty.has_non_self_typevar_or_typevar_instance(self.db(), env) + // a bound with no declaring type parameter is a legacy `TypeVar(bound=...)`: it has no + // list for a name to be earlier in, so every type variable it mentions is out of scope + let kind = match definition { + Some(definition) => bound_scope_violation_for(self.db(), env, definition, bound_ty), + None => bound_ty + .has_non_self_typevar_or_typevar_instance(self.db(), env) + .then_some(BoundScopeViolation::out_of_scope()), + }; + + if let Some(violation) = kind && let Some(builder) = self .context .report_lint(&INVALID_TYPE_VARIABLE_BOUND, bound) { - builder.into_diagnostic(format_args!("TypeVar {end} bound cannot be generic")); + let db = self.db(); + match violation { + BoundScopeViolation::OutOfScope => { + builder.into_diagnostic(format_args!("TypeVar {end} bound cannot be generic")); + } + BoundScopeViolation::SelfReference => { + builder.into_diagnostic(format_args!( + "TypeVar {end} bound cannot reference the type parameter it bounds" + )); + } + BoundScopeViolation::LaterInList(referenced) => { + builder.into_diagnostic(format_args!( + "TypeVar {end} bound cannot reference later type parameter `{name}`", + name = referenced.name(db), + )); + } + } } bound_ty @@ -395,6 +507,17 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let db = self.db(); + // a bound naming another type parameter is not a type yet, so it is read at its loosest: + // every parameter it names at that parameter's own ceiling. a default outside even that + // is outside the bound under every specialization, and one inside it is left to the + // specialization to judge + let bound_or_constraints = match bound_or_constraints { + TypeVarBoundOrConstraints::UpperBound(bound) => TypeVarBoundOrConstraints::UpperBound( + bound.with_typevars_at_declared_end(db, env, DeclaredEnd::Ceiling), + ), + constraints @ TypeVarBoundOrConstraints::Constraints(_) => constraints, + }; + // Normalize both typevar representations into a `TypeVarInstance` so they // follow the same compatibility rules: // - `Type::KnownInstance(TypeVar(..))` for legacy `typing.TypeVar(...)` values @@ -842,12 +965,26 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { /// [`InferenceFlags::IN_PACK_BOUND`] is what lets the double-starred whole-pack form /// resolve here: outside a pack's bound and outside a `**kwargs` annotation, a `**` type /// expression has no meaning. + /// + /// Unlike an ordinary bound, a pack's is not an interval on the pack's own value, so it never + /// reaches the constraint set. That is why it may not name another type parameter, even one + /// that is in scope: the check that enforces it walks members one at a time and has nowhere + /// to record a relation, so accepting the bound would leave every member unchecked. fn infer_pack_bound(&mut self, bound: &ast::Expr) -> Type<'db> { let previous = self.context.inference_flags; self.context.inference_flags |= InferenceFlags::IN_PACK_BOUND | InferenceFlags::IN_TYPE_VARIABLE_BOUND; let bound_ty = self.infer_type_expression(bound); self.context.inference_flags = previous; + + if bound_ty.has_non_self_typevar_or_typevar_instance(self.db(), self.program_environment()) + && let Some(builder) = self + .context + .report_lint(&INVALID_TYPE_VARIABLE_BOUND, bound) + { + builder.into_diagnostic("A variadic pack's bound cannot be generic"); + } + bound_ty } diff --git a/crates/ty_python_semantic/src/types/overlapping.rs b/crates/ty_python_semantic/src/types/overlapping.rs index b54da9a08d..321bbe3b78 100644 --- a/crates/ty_python_semantic/src/types/overlapping.rs +++ b/crates/ty_python_semantic/src/types/overlapping.rs @@ -49,13 +49,12 @@ impl<'db> OverlappingType<'db> { /// method body*: the upper bound of the wrapped type argument. A covariant /// `Key` is thereby erased to its bound (`object` when unbounded), so it can /// never be written back into `Key`-typed storage. + /// + /// A bound may itself be a type parameter, which is a name rather than the ceiling this + /// erasure needs, so `declared_ceiling` follows it to one. pub(crate) fn value_type(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Type<'db> { match self.type_argument(db) { - Type::TypeVar(typevar) => typevar - .typevar(db) - .bound_or_constraints(db, env) - .map(|bound_or_constraints| bound_or_constraints.as_type(db, env)) - .unwrap_or_else(Type::object), + Type::TypeVar(typevar) => typevar.typevar(db).declared_ceiling(db, env), other => other, } } diff --git a/crates/ty_python_semantic/src/types/reified_infer.rs b/crates/ty_python_semantic/src/types/reified_infer.rs index 0d83c135dd..8189cab160 100644 --- a/crates/ty_python_semantic/src/types/reified_infer.rs +++ b/crates/ty_python_semantic/src/types/reified_infer.rs @@ -35,7 +35,7 @@ use crate::types::instance::Protocol; use crate::types::literal::LiteralValueTypeKind; use crate::types::protocol_class::ReifiedMember; use crate::types::tuple::Tuple; -use crate::types::typevar::{TypeVarBoundOrConstraints, TypeVarKind}; +use crate::types::typevar::TypeVarKind; use crate::types::variance::TypeVarVariance; use crate::types::{KnownClass, MemberLookupPolicy, Type}; @@ -1569,14 +1569,7 @@ fn type_param_interface<'db>( .variables(db) .map(|bound_typevar| { let typevar = bound_typevar.typevar(db); - let admissible = match typevar.bound_or_constraints(db, env) { - Some(TypeVarBoundOrConstraints::UpperBound(bound)) => bound, - Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { - constraints.as_type(db, env) - } - None => Type::object(), - }; - (typevar.name(db), admissible) + (typevar.name(db), typevar.declared_ceiling(db, env)) }) .collect() }) diff --git a/crates/ty_python_semantic/src/types/signatures.rs b/crates/ty_python_semantic/src/types/signatures.rs index e7acd7ddb0..5af9e22702 100644 --- a/crates/ty_python_semantic/src/types/signatures.rs +++ b/crates/ty_python_semantic/src/types/signatures.rs @@ -1704,6 +1704,11 @@ impl<'db> Signature<'db> { if receiver.has_typevar(db, env) { return false; } + // a bound naming another type variable describes a relation, not a domain the receiver + // can be measured against on its own + if typevar.typevar(db).bound_mentions_typevars(db) { + return false; + } // basedpython: a use-site projection is a *view* of the receiver — `S[out int]` is an // `S[int]` a caller has undertaken only to read. Whether it satisfies the domain is a // question about the object, and the object is the same one, so a projected receiver is diff --git a/crates/ty_python_semantic/src/types/typevar.rs b/crates/ty_python_semantic/src/types/typevar.rs index c52bd560ec..dec033b372 100644 --- a/crates/ty_python_semantic/src/types/typevar.rs +++ b/crates/ty_python_semantic/src/types/typevar.rs @@ -6,6 +6,7 @@ use itertools::{Either, Itertools}; use ruff_db::parsed::parsed_module; use ruff_python_ast::name::Name; use ruff_python_ast::{self as ast, PySourceType}; +use ruff_text_size::Ranged; use rustc_hash::FxHashSet; use smallvec::SmallVec; @@ -26,7 +27,7 @@ use crate::{ tuple::Tuple, variance::VarianceInferable, visitor::{ - self, TypeCollector, TypeVisitor, any_over_type_with_opaque_self, + self, TypeCollector, TypeVisitor, any_over_type_with_opaque_self, find_over_type, walk_type_with_recursion_guard, }, }, @@ -34,9 +35,20 @@ use crate::{ use ty_python_core::{ Program, definition::{Definition, DefinitionKind}, + scope::{NodeWithScopeKind, ScopeKind}, semantic_index, }; +/// Which end of a type variable's own declaration stands in for it when a bound that names it has +/// to be read without a specialization. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DeclaredEnd { + /// The narrowest the variable can be — its declared lower bound, or `Never`. + Floor, + /// The widest the variable can be — its declared upper bound, or `object`. + Ceiling, +} + impl<'db> Type<'db> { pub(crate) const fn is_type_var(self) -> bool { matches!(self, Type::TypeVar(_)) @@ -59,6 +71,57 @@ impl<'db> Type<'db> { ) } + /// This type with every type variable it names replaced by one end of that variable's own + /// declaration. + /// + /// A bound may name another type parameter, and at the declaration nothing says what that + /// parameter is. Reading each one at the end that makes the surrounding bound *widest* gives + /// the most permissive type it can ever denote, so a check against it reports only what no + /// specialization could rescue: an upper bound is widest at its ceiling, a lower bound at its + /// floor. `def f[T, R: T..int]` is fine — `T` could be `bool` — while + /// `class C[S = int, T: Sequence[S] = int]` has a default no `S` makes a `Sequence` of. + /// + /// `Self` is left alone: it is bound by the receiver, not by the list being declared. + pub(crate) fn with_typevars_at_declared_end( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + end: DeclaredEnd, + ) -> Type<'db> { + /// A ceiling can name a type variable of its own, so the substitution repeats. Each round + /// replaces a variable declared strictly earlier than the last, which the scope rule + /// keeps acyclic — the cap is only there so a bound this rule never sanctioned cannot + /// spin. + const MAX_ROUNDS: usize = 8; + + let mut ty = self; + for _ in 0..MAX_ROUNDS { + let Some(bound_typevar) = find_over_type(db, env, ty, false, |ty| match ty { + Type::TypeVar(bound_typevar) if !bound_typevar.typevar(db).is_self(db) => { + Some(bound_typevar) + } + _ => None, + }) else { + break; + }; + let typevar = bound_typevar.typevar(db); + let replacement = match end { + DeclaredEnd::Ceiling => typevar.declared_ceiling(db, env), + DeclaredEnd::Floor => typevar.lower_bound(db).unwrap_or(Type::Never), + }; + ty = ty.apply_type_mapping( + db, + env, + &TypeMapping::ApplySpecialization(ApplySpecialization::Single( + bound_typevar, + replacement, + )), + TypeContext::default(), + ); + } + ty + } + pub(crate) fn has_typevar(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> bool { any_over_type(db, env, self, false, |ty| matches!(ty, Type::TypeVar(_))) } @@ -421,6 +484,12 @@ impl<'db> TypeVarInstance<'db> { if !self.is_pack(db) { return None; } + // a pack's bound never reaches the constraint set, so a bound naming another type + // parameter has nowhere to record the relation it describes. it is reported where it is + // written and dropped here, rather than silently checking nothing + if self.bound_mentions_typevars(db) { + return None; + } match self._bound_or_constraints(db)? { TypeVarBoundOrConstraintsEvaluation::Eager(TypeVarBoundOrConstraints::UpperBound( bound, @@ -443,6 +512,38 @@ impl<'db> TypeVarInstance<'db> { self.is_typevartuple(db) || self.is_keyword_variadic(db) } + /// Whether either end of this type variable's bound names another type variable. + /// + /// Such a bound is a relation between two variables, and the constraint set is what expresses + /// one — so the paths that treat a declared bound as a concrete type have to leave it alone + /// and let [`SpecializationBuilder`](crate::types::generics::SpecializationBuilder) conjoin + /// it instead. `Self` does not count: it is bound by the receiver, not by the generic context + /// being solved. + #[salsa::tracked(returns(copy), cycle_result=|_, _, _| false, heap_size=ruff_memory_usage::heap_size)] + pub(crate) fn bound_mentions_typevars(self, db: &'db dyn Db) -> bool { + // an eagerly-unbounded type variable is the common case and must not force anything + if self._bound_or_constraints(db).is_none() && self._lower_bound(db).is_none() { + return false; + } + let Some(definition) = self.definition(db) else { + return false; + }; + let env = ProgramEnvironment::from_definition(definition); + let mentions = |ty: Type<'db>| ty.has_non_self_typevar_or_typevar_instance(db, &env); + // read the bound directly rather than through `bound_or_constraints`, which hides a + // variadic pack's — a pack bound is one of the callers that needs this answer + let upper = match self._bound_or_constraints(db) { + Some(TypeVarBoundOrConstraintsEvaluation::Eager( + TypeVarBoundOrConstraints::UpperBound(bound), + )) => Some(bound), + Some(TypeVarBoundOrConstraintsEvaluation::LazyUpperBound) => self.lazy_bound(db, &env), + // constraints naming a type variable are rejected outright, so there is never one + // here to hide from the paths below + _ => None, + }; + upper.is_some_and(mentions) || self.lower_bound(db).is_some_and(mentions) + } + pub(crate) fn upper_bound( self, db: &'db dyn Db, @@ -482,6 +583,33 @@ impl<'db> TypeVarInstance<'db> { } } + /// The declared ceiling on this type variable, as a type rather than as a name. + /// + /// A bound may be another type parameter (`def f[T, R: T]`), and a name is no ceiling at all + /// to a caller that wants to measure a value against one — so the chain is followed until it + /// reaches something that is not a type variable. An unbounded parameter is capped by + /// `object`, and a constrained one by the union of its constraints. + /// + /// The scope rule makes the chain acyclic: a bound may only name a parameter declared before + /// it, and one that breaks that rule is dropped rather than installed. + pub(crate) fn declared_ceiling( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ) -> Type<'db> { + let mut typevar = self; + loop { + let ceiling = match typevar.bound_or_constraints(db, env) { + Some(bound_or_constraints) => bound_or_constraints.as_type(db, env), + None => return Type::object(), + }; + match ceiling { + Type::TypeVar(named) => typevar = named.typevar(db), + ceiling => return ceiling, + } + } + } + pub(crate) fn bound_or_constraints( self, db: &'db dyn Db, @@ -522,7 +650,6 @@ impl<'db> TypeVarInstance<'db> { heap_size=ruff_memory_usage::heap_size )] fn lazy_lower_bound(self, db: &'db dyn Db) -> Option> { - let env = &ProgramEnvironment::from_definition(self.definition(db)?); let definition = self.definition(db)?; let module = parsed_module(db, definition.program_file(db).python_file(db)).load(db); let DefinitionKind::TypeVar(typevar) = definition.kind(db) else { @@ -531,8 +658,9 @@ impl<'db> TypeVarInstance<'db> { let lower = definition_expression_type(db, definition, typevar.node(&module).lower_bound.as_ref()?); - // a generic lower bound is reported as an error and dropped, mirroring the upper bound - if lower.has_non_self_typevar_or_typevar_instance(db, env) { + // the lower end follows the same scope rule as the upper one, and is dropped for the + // same reason when it breaks it + if bound_scope_violation(db, self, lower).is_some() { return None; } @@ -890,12 +1018,13 @@ impl<'db> TypeVarInstance<'db> { } } - fn lazy_bound(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> Option> { + fn lazy_bound(self, db: &'db dyn Db, _env: &ProgramEnvironment<'db>) -> Option> { let bound = self.lazy_bound_unchecked(db)?; - // a generic bound is reported as an error and dropped, but `Self` is a legitimate bound: - // it is bound by the enclosing class, and is substituted when the method binds its receiver - if bound.has_non_self_typevar_or_typevar_instance(db, env) { + // a bound naming a type parameter that is already in scope is kept — `def f[T, R: T]`. + // one naming a parameter that is not is reported *and dropped*, because the consumers + // that reduce a type variable to its bound recurse into it + if bound_scope_violation(db, self, bound).is_some() { return None; } @@ -1106,6 +1235,132 @@ impl<'db> TypeVarInstance<'db> { } } +/// How a type variable's bound names a type variable it may not name. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) enum BoundScopeViolation<'db> { + /// `def f[T: list[T]]` — the parameter is not in scope inside its own bound. + SelfReference, + /// `def f[S: T, T]` — PEP 695 allows a bound to name an earlier parameter, not a later one. + LaterInList(TypeVarInstance<'db>), + /// A legacy `TypeVar(bound=U)`, or a name no enclosing type-parameter list declares. + OutOfScope, +} + +impl BoundScopeViolation<'_> { + pub(crate) const fn out_of_scope() -> Self { + Self::OutOfScope + } +} + +/// Classifies every type variable `bound` names against the list `own_definition` belongs to. +/// +/// A type parameter may name one that is already in scope where it is written: an earlier entry +/// in the same list, or one belonging to an enclosing type-parameter list. Because the entries of +/// a list appear in source order, "earlier" is decided by comparing offsets rather than by +/// walking the list. +/// +/// `Self` is exempt. It is bound by the enclosing class rather than by the list being declared, +/// and `def method[T: Self]` is checked when the method binds its receiver. +/// +/// The rule is decided in one place because two callers need the same answer for different +/// reasons: the diagnostic reports it, and [`lazy_bound`](TypeVarInstance::lazy_bound) has to +/// *drop* a bound that breaks it. Dropping matters more than the message — several consumers +/// reduce a type variable to its bound by plain recursion, so installing the mutually recursive +/// bounds of `def f[T: R, R: T]` would be a stack overflow rather than an error. +pub(crate) fn bound_scope_violation_for<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + own_definition: Definition<'db>, + bound: Type<'db>, +) -> Option> { + let program_file = own_definition.program_file(db); + let file = own_definition.file(db); + let own_scope = own_definition.file_scope(db); + let index = semantic_index(db, program_file); + let module = parsed_module(db, program_file.python_file(db)).load(db); + let own_start = Ranged::start(&own_definition.full_range(db, &module)); + + // only a type parameter has a list to be early or late in. a legacy `TypeVar(bound=U)` is + // declared by an assignment, so there is no position for `U` to precede — and one `TypeVar` + // object can be reused by two unrelated generics, which is why the rule cannot be relaxed + // for it by looking at the surrounding scope instead + let in_type_param_list = index.scope(own_scope).kind() == ScopeKind::TypeParams; + + // The one enclosing list a bound may name, if there is one: the class's, when this list + // belongs to one of that class's own methods. That is the only enclosing case anything + // substitutes — projecting a member from `Owner[int]` applies `Owner`'s specialization to the + // method's signature, and the bound is rewritten with it. Nothing does that for a list on a + // nested class or a nested function, so naming an enclosing parameter there would leave a + // variable in the bound that no specialization ever reaches, and every use of the generic + // would fail a bound it cannot satisfy. + // + // The chain for a method is exactly [its own list, the class body, the class's list]; anything + // longer has crossed something that does not carry the parameter along. + let method_owner_type_params = index + .ancestor_scopes(own_scope) + .skip(1) + .take(2) + .collect_tuple() + .filter(|((_, body), (_, type_params))| { + matches!( + index.scope(own_scope).node(), + NodeWithScopeKind::FunctionTypeParameters(_) + ) && body.kind().is_class() + && matches!( + type_params.node(), + NodeWithScopeKind::ClassTypeParameters(_) + ) + }) + .map(|(_, (type_params_scope, _))| type_params_scope); + + // the bound's own lazy attributes are not searched: a name this bound writes is a type + // variable of its own, and what *that* variable is bounded by is its own declaration's problem + find_over_type(db, env, bound, false, |ty| { + let referenced = match ty { + Type::TypeVar(bound_typevar) => bound_typevar.typevar(db), + Type::KnownInstance(KnownInstanceType::TypeVar(typevar)) => typevar, + _ => return None, + }; + if referenced.is_self(db) { + return None; + } + Some(match referenced.definition(db) { + Some(definition) if definition == own_definition => BoundScopeViolation::SelfReference, + Some(definition) + if in_type_param_list + && definition.file(db) == file + && definition.file_scope(db) == own_scope => + { + if Ranged::start(&definition.full_range(db, &module)) < own_start { + return None; + } + BoundScopeViolation::LaterInList(referenced) + } + Some(definition) + if in_type_param_list + && definition.file(db) == file + && method_owner_type_params == Some(definition.file_scope(db)) => + { + return None; + } + _ => BoundScopeViolation::OutOfScope, + }) + }) +} + +/// The cached form of [`bound_scope_violation_for`], for the callers that have to consult it +/// whenever a bound is read rather than once per definition. +#[salsa::tracked(returns(copy), cycle_result=|_, _, _, _| None, heap_size=ruff_memory_usage::heap_size)] +fn bound_scope_violation<'db>( + db: &'db dyn Db, + typevar: TypeVarInstance<'db>, + bound: Type<'db>, +) -> Option> { + let own_definition = typevar.definition(db)?; + let env = ProgramEnvironment::from_definition(own_definition); + bound_scope_violation_for(db, &env, own_definition, bound) +} + /// A nonce that gives a bound typevar occurrence a fresh identity. /// /// `0` is reserved for source-level, non-freshened typevars. Positive values identify fresh @@ -1867,14 +2122,26 @@ impl<'db> BoundTypeVarInstance<'db> { && specialization.specialize_self_domain() && let Some(specialization) = specialization.as_specialization(db) { - Type::TypeVar(self.apply_specialization_to_bound_or_constraints( + return Type::TypeVar(self.apply_specialization_to_bound_or_constraints( db, specialization, visitor.env, - )) - } else { - Type::TypeVar(self) + )); + } + // a type variable the specialization does not name can still be bounded by one it + // does — `class Owner[T]: def narrow[U: T]`, projected from an `Owner[int]`. the + // bound is read off the variable wherever it turns up, so the variable itself has to + // carry the substituted bound + if self.typevar(db).bound_mentions_typevars(db) { + return Type::TypeVar(self.with_mapped_bound_and_default( + db, + env, + self.freshness(db), + type_mapping, + visitor, + )); } + Type::TypeVar(self) }; match type_mapping { @@ -2045,6 +2312,13 @@ impl<'db> BoundTypeVarInstance<'db> { let env = ProgramEnvironment::from_program(bound_typevar.binding_context(db).program(db)); + // every caller intersects the result into a *specialization*, so a bound naming + // another type variable has nothing to offer here: substituting it would leave that + // variable in the specialization + if bound_typevar.typevar(db).bound_mentions_typevars(db) { + return None; + } + bound_typevar .typevar(db) .bound_or_constraints(db, &env) diff --git a/docs/basedpython/features/bound-ranges.md b/docs/basedpython/features/bound-ranges.md index e0d7ba9e93..276322fcc2 100644 --- a/docs/basedpython/features/bound-ranges.md +++ b/docs/basedpython/features/bound-ranges.md @@ -75,10 +75,18 @@ class C[T: str..object = int]: ... # error: default `int` is not assignable fr class D[T: str..str = object]: ... # error: default `object` is not assignable to upper bound `str` ``` -## `Self` is a valid lower end +## either end can name a type parameter already in scope -`Self` is bound by the enclosing class, not by the generic context being declared, so it is exempt -from the rule that a bound cannot be generic — at either end: +both ends follow the same scope rule as a plain upper bound — see +[generic parameter syntax](generics.md#a-bound-can-name-another-type-parameter): + +```by +def g[T, U: T..object](t: T, u: U) -> U: + return u +``` + +`Self` is not one of those names: it is bound by the enclosing class rather than by the list being +declared, and binding the receiver settles it. it is a valid lower end: ```by class C: diff --git a/docs/basedpython/features/generics.md b/docs/basedpython/features/generics.md index 1f7ac2f824..dc36d3dfc7 100644 --- a/docs/basedpython/features/generics.md +++ b/docs/basedpython/features/generics.md @@ -108,6 +108,53 @@ class A[Fn: (*: *, **: *) -> object]: def f(self, *args: *Fn.parameters, **kwargs: **Fn.parameters) -> Fn.returns ``` +## a bound can name another type parameter + +a bound may name a type parameter that is already in scope where it is written — one that precedes +it in the same list, or one belonging to an enclosing list: + +```by +def pick[T, R: T](t: T, r: R) -> T: + return r + +class Owner[T]: + def narrow[U: T](self, u: U) -> T: + return u +``` + +the bound takes part in solving a call rather than being checked against one argument at a time, so +`R`'s solution is a floor under `T`: + +```by +class Animal +class Dog(Animal) + +def pick[T, R: T](t: T, r: R) -> T: + return t + +pick(Dog(), Animal()) # T is Animal +``` + +nothing else has to mention `T` for it to be found: + +```by +def only_bound[T, R: T](r: R) -> T: + return r + +only_bound(1) # T is Literal[1] +``` + +a name that is not yet in scope is rejected: a later entry in the list, the parameter's own name, +and a legacy `TypeVar`, which holds no position in a list at all: + +```by +def f[S: T, T](s: S, t: T) # error: `T` comes later +def g[T: list[T]](x: T) -> T # error: `T` is not in scope inside its own bound +``` + +a [variadic pack](pack-bounds.md)'s bound describes its members rather than its own value, so it is +checked member by member and cannot name a type parameter. + ## see also - [bounds on a variadic pack](pack-bounds.md) — what a bound means on a `*Args` or `**Kwargs` diff --git a/ty.schema.json b/ty.schema.json index 1c6114c157..ed5cd6f832 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -1652,7 +1652,7 @@ }, "invalid-type-variable-bound": { "title": "detects invalid type variable bounds", - "description": "## What it does\n\nChecks for [type variables][type variable] whose bounds reference type variables.\n\n## Why is this bad?\n\nThe bound of a type variable must be a concrete type.\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nfrom typing import TypeVar\n\n# error: [invalid-type-variable-bound]\nRecursiveT = TypeVar(\"RecursiveT\", bound=list[\"RecursiveT\"])\nU = TypeVar(\"U\")\n# error: [invalid-type-variable-bound]\nBoundT = TypeVar(\"BoundT\", bound=U)\n\n\ndef f[T: list[T]](): ... # error: [invalid-type-variable-bound]\ndef g[U, T: U](): ... # error: [invalid-type-variable-bound]\n```\n\n[type variable]: https://docs.python.org/3/library/typing.html#typing.TypeVar", + "description": "## What it does\n\nChecks for [type variables][type variable] whose bounds reference type variables that are not in\nscope where the bound is written.\n\n## Why is this bad?\n\nA type parameter's bound may reference a type parameter that is already in scope *and* substituted\nwhere the bound is used: an earlier entry in the same type parameter list, or — for a method — an\nentry in its class's list, which binding the receiver settles.\n\nAnything else has nothing to resolve to. A later entry is not yet in scope, a parameter is not in\nscope inside its own bound, and a legacy `TypeVar` is declared by an assignment, so it has no list\nto hold a position in. A nested class or nested function is in scope but is never substituted, so\nthe reference would still be standing at every use of the generic. A variadic pack's bound describes\nits members rather than the pack's own value, so it cannot reference a type parameter at all.\n\n## Examples\n\n```toml\n[environment]\npython-version = \"3.12\"\n```\n\n```python\nfrom typing import TypeVar\n\n# error: [invalid-type-variable-bound]\nRecursiveT = TypeVar(\"RecursiveT\", bound=list[\"RecursiveT\"])\nU = TypeVar(\"U\")\n# error: [invalid-type-variable-bound]\nBoundT = TypeVar(\"BoundT\", bound=U)\n\n\ndef f[T: list[T]](): ... # error: [invalid-type-variable-bound]\ndef g[T: U, U](): ... # error: [invalid-type-variable-bound]\n\n\n# `U` precedes `T`, so `T`'s bound can name it\ndef h[U, T: U](x: U, y: T): ...\n\n\nclass Owner[U]:\n # the receiver settles `U`, so a method's bound can name it\n def narrow[T: U](self, x: T) -> U:\n return x\n\n # nothing settles `U` for a nested class\n class Inner[T: U]: ... # error: [invalid-type-variable-bound]\n```\n\n[type variable]: https://docs.python.org/3/library/typing.html#typing.TypeVar", "default": "error", "oneOf": [ { From f9efcf9229b5219a0aa3b37f843d0696af0bd9fa Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Mon, 7 Sep 2026 06:41:39 +1000 Subject: [PATCH 08/11] regenerate the ty schema and rule docs after the hooks that reformat their input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a lint doc's bytes are copied verbatim into `ty.schema.json` and `crates/ty/docs/rules.md`, and three hooks reformat those docs — mdformat, markdownlint-fix, and `mdtest format`. running `cargo dev generate-all` before them leaves both artifacts holding the unformatted text, which `ruff_dev`'s `generate_ty_schema` and `ty_rules_up_to_date` tests then fail on. the hook is scoped to the lint docs, so an ordinary commit does not pay for a `ruff_dev` build. Co-Authored-By: Claude Opus 5 --- .pre-commit-config.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fed7009b00..32d926f708 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -196,3 +196,19 @@ repos: files: '^crates/.*/resources/(mdtest|lint_docs)/.*\.md$' pass_filenames: true priority: 2 + + # Priority 3: regeneration runs after everything that rewrites a codegen input. + # A lint doc's bytes are copied verbatim into `ty.schema.json` and + # `crates/ty/docs/rules.md`, and three earlier hooks reformat those docs + # (mdformat, markdownlint-fix, `mdtest format`). Regenerating before them + # leaves both artifacts holding the unformatted text, which `ruff_dev`'s + # `generate_ty_schema` and `ty_rules_up_to_date` tests then fail on. + - repo: local + hooks: + - id: generate-all + name: regenerate schemas and docs from the lint docs + entry: cargo dev generate-all + language: system + files: '^crates/.*/resources/lint_docs/.*\.md$' + pass_filenames: false + priority: 3 From 763f80b08d40cf9aea018d19167b1cf851841891 Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:56:30 +1000 Subject: [PATCH 09/11] support basedpython-ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the framework — its `@composable` and `@builder` decorators, its observables and the content blocks they are read in — is recognized as a dedicated module, which the rest of this rests on. unlike the other frameworks it is recognized wherever it resolves, first-party included, because it is developed in place. - eight lints over a composition: the content-block control flow rules, the writes a composition may not make, and `unobservable-dependency`, which keeps a composition from depending on a value nothing can observe changing - the observables a `def` reads while composing, inferred through its callees, and what a state write re-runs — the `inferredReads`, `parameterStability`, `derivedDependencies` and `inferredInvalidations` inlay hints - the block fixes the framework's api needs: a generic callee's block binds `it` from what the call solves for it, a declaration inside a block is the block's own local rather than a capture, and a `context` parameter is filled ahead of the block's own keyword two fixes fall out of that last one. the parser only lets a *borrowed* callback — one carrying `once` or `local` — follow a `context` parameter, because anything else a positional argument could reach would bind to the context parameter instead. and the lowering writes a separator between a block's keyword and an implicit `context` argument, which otherwise ran together: `Card():` with a context parameter lowered to `Card(theme=themecontent=_trailing_lambda_0)`. `docs/basedpython/frameworks/basedpython-ui.md` describes the model. Co-Authored-By: Claude Opus 5 --- .config/nextest.toml | 20 +- crates/by_transforms/src/lib.rs | 52 + .../src/transforms/context_params.rs | 60 + .../src/transforms/trailing_lambda.rs | 55 +- .../tests/block_scoping_runtime.rs | 128 + .../tests/reexport_conversion_runtime.rs | 126 + crates/ruff_python_ast/src/nodes.rs | 20 + .../src/parser/statement.rs | 36 +- crates/ty/docs/rules.md | 759 ++++-- crates/ty/tests/mdtest_divergence.rs | 47 + crates/ty_ide/src/inlay_hints.rs | 1114 ++++++++- ...sts__basedpython_derived_dependencies.snap | 91 + ...ts__tests__basedpython_inferred_reads.snap | 216 ++ ...nts__tests__basedpython_invalidations.snap | 1070 +++++++++ ...ests__basedpython_parameter_stability.snap | 27 + ...tests__basedpython_ui_counter_example.snap | 79 + ...s__tests__basedpython_ui_form_example.snap | 395 ++++ ...s__tests__basedpython_ui_todo_example.snap | 239 ++ crates/ty_module_resolver/src/module.rs | 87 +- crates/ty_python_core/src/builder.rs | 62 +- .../lint_docs/content-block-control-flow.md | 32 + .../mdtest/basedpython_ui_block_call.md | 81 + .../mdtest/basedpython_ui_block_let.md | 67 + .../basedpython_ui_context_before_block.md | 80 + .../mdtest/basedpython_ui_generic_block.md | 98 + .../resources/mdtest/basedpython_ui_lints.md | 1930 +++++++++++++++ ...asedpython_ui_loop_capture_through_once.md | 87 + .../mdtest/basedpython_ui_recognition.md | 117 + .../ty_python_semantic/src/semantic_model.rs | 8 +- crates/ty_python_semantic/src/types.rs | 11 + .../src/types/call/arguments.rs | 9 + .../ty_python_semantic/src/types/call/bind.rs | 26 +- .../src/types/class/known.rs | 67 + .../src/types/class/slots.rs | 5 + .../src/types/composition.rs | 2076 +++++++++++++++++ .../src/types/context_params.rs | 6 +- .../src/types/conversions.rs | 85 +- .../src/types/dedicated/basedpython_ui.rs | 449 ++++ .../src/types/dedicated/mod.rs | 1 + .../src/types/dedicated/pytest.rs | 6 +- .../src/types/dedicated/role.rs | 21 +- .../src/types/diagnostic.rs | 324 +++ .../ty_python_semantic/src/types/function.rs | 86 + .../src/types/ide_support.rs | 178 +- .../src/types/immutability.rs | 305 +++ .../src/types/infer/builder.rs | 95 +- .../src/types/infer/builder/function.rs | 45 +- .../ty_python_semantic/src/types/lifetimes.rs | 57 +- .../src/types/state_invalidations.rs | 1387 +++++++++++ .../src/types/state_reads.rs | 1129 +++++++++ .../src/types/trailing_lambda.rs | 206 +- .../src/server/api/requests/inlay_hints.rs | 7 + crates/ty_server/src/session/options.rs | 8 + .../e2e__commands__debug_command.snap | 4 + .../ty_vendored/ty_extensions/_internal.pyi | 9 + crates/ty_wasm/src/lib.rs | 4 + docs/basedpython/features/editor.md | 48 +- docs/basedpython/frameworks/basedpython-ui.md | 191 ++ docs/basedpython/frameworks/index.md | 7 + hawk.toml | 8 - ty.schema.json | 80 + zensical.toml | 1 + 62 files changed, 13751 insertions(+), 373 deletions(-) create mode 100644 crates/by_transforms/tests/block_scoping_runtime.rs create mode 100644 crates/by_transforms/tests/reexport_conversion_runtime.rs create mode 100644 crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_derived_dependencies.snap create mode 100644 crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_inferred_reads.snap create mode 100644 crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_invalidations.snap create mode 100644 crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_parameter_stability.snap create mode 100644 crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_ui_counter_example.snap create mode 100644 crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_ui_form_example.snap create mode 100644 crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_ui_todo_example.snap create mode 100644 crates/ty_python_semantic/resources/lint_docs/content-block-control-flow.md create mode 100644 crates/ty_python_semantic/resources/mdtest/basedpython_ui_block_call.md create mode 100644 crates/ty_python_semantic/resources/mdtest/basedpython_ui_block_let.md create mode 100644 crates/ty_python_semantic/resources/mdtest/basedpython_ui_context_before_block.md create mode 100644 crates/ty_python_semantic/resources/mdtest/basedpython_ui_generic_block.md create mode 100644 crates/ty_python_semantic/resources/mdtest/basedpython_ui_lints.md create mode 100644 crates/ty_python_semantic/resources/mdtest/basedpython_ui_loop_capture_through_once.md create mode 100644 crates/ty_python_semantic/resources/mdtest/basedpython_ui_recognition.md create mode 100644 crates/ty_python_semantic/src/types/composition.rs create mode 100644 crates/ty_python_semantic/src/types/dedicated/basedpython_ui.rs create mode 100644 crates/ty_python_semantic/src/types/immutability.rs create mode 100644 crates/ty_python_semantic/src/types/state_invalidations.rs create mode 100644 crates/ty_python_semantic/src/types/state_reads.rs create mode 100644 docs/basedpython/frameworks/basedpython-ui.md diff --git a/.config/nextest.toml b/.config/nextest.toml index bd055013dd..a1265b39ed 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -39,9 +39,27 @@ final-status-level = "slow" # than the 21% of headroom that left, and the same commit timed out at 360s on two # runs out of three while passing at 284s on the third. 10 minutes puts the cost # back under half the ceiling and still catches a deadlock. +# +# And it drifted back again, the same way. On windows the harness measured 561.1s +# on main against the 600s ceiling — 6.5% of headroom — and basedpython-ui's +# mdtests, which took the runnable corpus from 1086 blocks to 1137 (+4.7%), were +# enough to exceed it. The cost is a transpile/python subprocess pair per block, +# and windows spawns processes far more slowly than linux, so windows is where the +# ceiling binds first. 20 minutes puts the cost back under half, as before. +# +# The harness already parallelises internally over a work-stealing pool, so this +# is not a serialisation problem: it is that the corpus only ever grows while a CI +# runner has two cores. Cutting the per-block cost is the lever, and the harness +# now takes the cheapest one — a block naming a module the interpreter does not +# have is dropped before it is transpiled rather than after, which measured 46.3s +# to 40.4s (12.7%) locally. That more than pays for the blocks added here, but the +# ceiling is raised anyway: 561.1s of 600s was already too close before any of +# them. The next lever, if this drifts a fourth time, is calling the transpiler +# in-process instead of spawning `by` per block, which would remove half the +# process spawns outright. [[profile.ci.overrides]] filter = 'test(clean_mdtest_blocks_run)' -slow-timeout = { period = "60s", terminate-after = 10 } +slow-timeout = { period = "60s", terminate-after = 20 } # External-dependency mdtests provision a real virtualenv with `uv` (a network # install of the framework plus its stubs) before type-checking, so on the fork's diff --git a/crates/by_transforms/src/lib.rs b/crates/by_transforms/src/lib.rs index d8dfdd989a..a6e815d9a9 100644 --- a/crates/by_transforms/src/lib.rs +++ b/crates/by_transforms/src/lib.rs @@ -1676,6 +1676,58 @@ mod cross_file { transpile_typed(project.db(), file, config, Some(&rebuild)).expect("transpile failed") } + /// a literal conversion whose target is reached through a package + /// re-export: `Dp` is declared in `ui.geometry` and re-exported by + /// `ui/__init__`, and the file only imports `ui`. the conversion must + /// import the class from its declaring module — spelled through the + /// package the file does import — under the mangled alias, instead of + /// giving up because no import names `ui.geometry` itself + #[test] + fn conversion_target_reached_through_a_re_export_imports_its_declaring_module() { + let project = project_db(&[ + ("/ui/__init__.by", "from .geometry export Dp\n"), + ( + "/ui/geometry.by", + "frozen data class Dp:\n value: float\n\n class def __of__(cls, value: int | float) -> Dp:\n return Dp(float(value))\n", + ), + ( + "/main.by", + "from ui import Dp\n\ndef pad(amount: Dp) -> float:\n return amount.value\n\nprint(pad(8))\n", + ), + ]); + let out = transpile_file(&project, "/main.by", &Config::test_default()); + assert!( + out.contains("from ui.geometry import Dp as _by_conv__Dp"), + "the declaring module is spelled through the imported package, got:\n{out}" + ); + assert!( + out.contains("print(pad(_by_conv__Dp.__of__(8)))"), + "the literal converts through the alias, got:\n{out}" + ); + } + + /// the same through a relative import inside the package: the spelling + /// stays relative, so it does not depend on how the package is rooted + #[test] + fn conversion_target_reached_through_a_relative_re_export_stays_relative() { + let project = project_db(&[ + ("/ui/__init__.by", "from .geometry export Dp\n"), + ( + "/ui/geometry.by", + "frozen data class Dp:\n value: float\n\n class def __of__(cls, value: int | float) -> Dp:\n return Dp(float(value))\n", + ), + ( + "/ui/widgets.by", + "from . import Dp\n\ndef pad(amount: Dp) -> float:\n return amount.value\n\nprint(pad(8))\n", + ), + ]); + let out = transpile_file(&project, "/ui/widgets.by", &Config::test_default()); + assert!( + out.contains("from .geometry import Dp as _by_conv__Dp"), + "got:\n{out}" + ); + assert!(out.contains("_by_conv__Dp.__of__(8)"), "got:\n{out}"); + } /// `f[int](1)` must lower to `f(1)` only because ty resolves the imported /// `f` to a generic *function* (constructor calls like `Foo[int](1)` keep /// their args). that resolution requires cross-module type info — the diff --git a/crates/by_transforms/src/transforms/context_params.rs b/crates/by_transforms/src/transforms/context_params.rs index dd2e800403..19b6447e15 100644 --- a/crates/by_transforms/src/transforms/context_params.rs +++ b/crates/by_transforms/src/transforms/context_params.rs @@ -335,6 +335,66 @@ mod tests { ); } + #[test] + fn block_carrying_call_receives_implicit_argument() { + // a call that carries a trailing block is still a call: its `context` + // parameter is filled like any other, ahead of the block's own keyword + check( + indoc! {r#" + def Card(title: str, context theme: str, once content: () -> None): + content() + + context theme = "dark" + + Card("x"): + pass + "#}, + indoc! {r#" + from typing import Callable + def Card(title: str, theme: str, content: Callable[[], None]): + content() + + theme = "dark" + + def _trailing_lambda_0(it=None): + pass + Card("x", theme=theme, content=_trailing_lambda_0) + "#}, + ); + } + + #[test] + fn block_carrying_call_with_no_written_arguments_separates_them() { + // the block's keyword and the implicit `context` argument are spliced + // in at the same point — before the closing paren — and each decides + // its own separator from the source, which has nothing between the + // parens to separate from. without one of them accounting for the + // other the two run together as `Card(theme=themecontent=...)`, which + // is not python + check( + indoc! {r#" + def Card(context theme: str, once content: () -> None): + content() + + context theme = "dark" + + Card(): + pass + "#}, + indoc! {r#" + from typing import Callable + def Card(theme: str, content: Callable[[], None]): + content() + + theme = "dark" + + def _trailing_lambda_0(it=None): + pass + Card(theme=theme, content=_trailing_lambda_0) + "#}, + ); + } + #[test] fn trailing_lambda_receiver_fills_a_context_parameter() { // the block's receiver is spelled `self` in the source but has a name of diff --git a/crates/by_transforms/src/transforms/trailing_lambda.rs b/crates/by_transforms/src/transforms/trailing_lambda.rs index ac73d7cd9f..33394a84b9 100644 --- a/crates/by_transforms/src/transforms/trailing_lambda.rs +++ b/crates/by_transforms/src/transforms/trailing_lambda.rs @@ -73,19 +73,31 @@ struct TrailingLambdaLower<'a, 'src> { } /// Collects the `Name` targets *assigned* directly in a block — every rebinding, -/// via `=`, `for`, `with as`, `:=`, or augmented / annotated assignment (ruff -/// marks them all [`ExprContext::Store`]). Attribute / subscript targets -/// (`a.b = …`) don't rebind a name, so their `Load`-context root is skipped. -/// Nested functions, classes, lambdas, and comprehensions are their own scope -/// and are not descended into. +/// via `=`, `for`, `with as`, `:=`, or augmented assignment (ruff marks them all +/// [`ExprContext::Store`]). Attribute / subscript targets (`a.b = …`) don't +/// rebind a name, so their `Load`-context root is skipped. An *annotated* +/// assignment — a `let` / `var` declaration, `x: T = …` — is not collected +/// either: python gives an annotated name to the function that annotates it and +/// rejects `nonlocal` on it, so such a name is the block's own local, as it is +/// for the checker. Nested functions, classes, lambdas, and comprehensions are +/// their own scope and are not descended into. struct BlockAssignments<'ast> { names: Vec<&'ast Expr>, } impl<'ast> Visitor<'ast> for BlockAssignments<'ast> { fn visit_stmt(&mut self, stmt: &'ast Stmt) { - if matches!(stmt, Stmt::FunctionDef(_) | Stmt::ClassDef(_)) { - return; + match stmt { + Stmt::FunctionDef(_) | Stmt::ClassDef(_) => return, + // the declaration's value may still assign (a walrus), so it is + // walked; only the declared target is left out + Stmt::AnnAssign(declaration) => { + if let Some(value) = &declaration.value { + self.visit_expr(value); + } + return; + } + _ => {} } walk_stmt(self, stmt); } @@ -364,7 +376,15 @@ impl TrailingLambdaLower<'_, '_> { .iter_source_order() .map(|arg| arg.range().end()) .max(); + // the `context` lowering splices its own arguments in at + // this same point, and it decides *its* separator from the + // source alone — which has nothing in the parens to + // separate from. so a call that writes no arguments but + // fills a `context` parameter still needs one here, or the + // two insertions run together: `f(theme=themeblock=...)` + let fills_context = !self.types.implicit_context_arguments(call).is_empty(); let separator = match last_argument_end { + None if fills_context => ", ", None => "", // a trailing comma in the source already separates Some(end) @@ -521,6 +541,27 @@ mod tests { transpile(input, &Config::test_default()).unwrap() } + #[test] + fn a_declaration_inside_a_block_is_a_block_local() { + // `let user` declares the block's own local: no `nonlocal` (python + // rejects `nonlocal` on an annotated name), even though the enclosing + // function binds `user` too + let out = check(indoc! {" + def run(once block: () -> None): + block() + + def main(user: str) -> None: + run: + let user = 1 + print(user) + print(user) + "}); + assert!(!out.contains("nonlocal"), "got:\n{out}"); + assert!( + out.contains(" def _trailing_lambda_0(it=None):\n user: Final = 1"), + "got:\n{out}" + ); + } #[test] fn a_block_that_awaits_lowers_to_an_async_def() { // the block is a function of its own, so `await` in it makes *it* a diff --git a/crates/by_transforms/tests/block_scoping_runtime.rs b/crates/by_transforms/tests/block_scoping_runtime.rs new file mode 100644 index 0000000000..dbc7f4585c --- /dev/null +++ b/crates/by_transforms/tests/block_scoping_runtime.rs @@ -0,0 +1,128 @@ +//! Runtime divergence tests for how a trailing-lambda block scopes its names. +//! +//! The mdtests fix what the *checker* resolves inside a block — a `let` that is +//! the block's own, an `it` that always belongs to the innermost block — and the +//! transform unit tests fix the lowered text. This test closes the loop on a +//! real interpreter, on the two points where the lowered text either holds or +//! fails in a way neither of those would see: a block declares `it=None` (and a +//! receiver ahead of it) whatever its callback passes, so a callback that passes +//! fewer arguments must still be able to call it; and a `let` inside a block +//! whose name the enclosing function also binds must not lower to `nonlocal x` + +//! `x: Final = …`, which python rejects at compile time. +//! +//! No third-party packages are needed, so any `python3` will do; if none is +//! found the test skips rather than fails. + +#![expect( + clippy::print_stderr, + reason = "a skipped test must say why it skipped, or it reads as a pass" +)] + +use std::process::Command; + +use by_transforms::{Config, PythonVersion, transpile}; + +mod common; +use common::python; + +/// a block declares `it=None` whatever its callback passes, so a callback that +/// passes fewer arguments than the block declares can still call it — the +/// property the unconditional declaration rests on. a receiver callback passes +/// its receiver and no `it`, which must bind the receiver parameter rather than +/// `it` +const UNFILLED_PARAMETERS_PROGRAM: &str = r#" +def handler(block: () -> None): + block() + +def against(block: str.() -> None): + "receiver".block() + +seen: list[str] = [] + +def main() -> None: + handler: + seen.append("handled") + against: + seen.append(upper()) + +main() +assert seen == ["handled", "RECEIVER"], seen +print("ok") +"#; + +/// a `let` declared inside a block is the block's own local even when the +/// enclosing function binds the same name — here a `match` capture — so the +/// lowering must not make it `nonlocal`, which an annotated name cannot be +const BLOCK_LET_PROGRAM: &str = r#" +import asyncio +from typing import Awaitable + +async def load(name: str) -> str: + return name.upper() + +async def scope(once block: () -> Awaitable[None]): + await block() + +def run(once block: () -> None): + block() + +seen: list[str] = [] + +async def main() -> None: + match "morgan": + case str() as user: + await scope(): + let user = await load("nested") + seen.append(user) + seen.append(user) + # a synchronous block and an annotated declaration behave the same way + total: int = 1 + run: + total: int = 2 + seen.append(str(total)) + seen.append(str(total)) + +asyncio.run(main()) +assert seen == ["NESTED", "morgan", "2", "1"], seen +print("ok") +"#; + +fn run_program(python: &str, program: &str, what: &str) { + let config = Config { + min_version: PythonVersion::PY313, + ..Config::default() + }; + let transpiled = transpile(program, &config).expect("transpile should succeed"); + + let output = Command::new(python) + .arg("-c") + .arg(&transpiled) + .output() + .expect("failed to spawn python"); + + assert!( + output.status.success(), + "transpiled {what} program failed on {python}:\n--- stdout ---\n{}\n--- stderr ---\n{}\n--- transpiled ---\n{transpiled}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "ok"); +} + +#[test] +fn a_callback_passing_fewer_arguments_can_still_call_the_block() { + let Some(python) = python() else { + eprintln!("skipping block-scoping runtime test: no `python3` interpreter found"); + return; + }; + run_program(&python, UNFILLED_PARAMETERS_PROGRAM, "unfilled-parameters"); +} + +#[test] +fn let_inside_a_block_is_a_block_local() { + let Some(python) = python() else { + eprintln!("skipping block-scoping runtime test: no `python3` interpreter found"); + return; + }; + run_program(&python, BLOCK_LET_PROGRAM, "block-`let`"); +} diff --git a/crates/by_transforms/tests/reexport_conversion_runtime.rs b/crates/by_transforms/tests/reexport_conversion_runtime.rs new file mode 100644 index 0000000000..15e558c6ad --- /dev/null +++ b/crates/by_transforms/tests/reexport_conversion_runtime.rs @@ -0,0 +1,126 @@ +//! Runtime divergence test for a literal conversion whose target class is +//! reached through a package re-export. +//! +//! `Dp` is declared in `ui.geometry` and re-exported by `ui/__init__` +//! (`from .geometry export Dp`); the program only imports `ui`. The checker +//! accepts `pad(8)` through `Dp.__of__`, and the lowering has to import `Dp` +//! from its declaring module under the conversion alias — spelled through the +//! package the file does import. The transform unit tests fix that text; this +//! test proves the emitted import actually resolves on a real interpreter, +//! with the package laid out on disk the way the checker resolved it. +//! +//! The lowering needs cross-module type information, so the project is built +//! through a typed db rather than the single-file `transpile`. No third-party +//! packages are needed; if no `python3` is found the test skips rather than +//! fails. + +#![expect( + clippy::print_stderr, + reason = "a skipped test must say why it skipped, or it reads as a pass" +)] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use by_transforms::{Config, PythonVersion, transpile_typed}; +use ruff_db::files::system_path_to_file; +use ruff_db::system::{DbWithWritableSystem, SystemPathBuf}; +use ty_project::{ProjectMetadata, TestDb}; + +mod common; +use common::python; + +const PACKAGE_INIT: &str = "from .geometry export Dp\n"; + +const GEOMETRY: &str = r#" +frozen data class Dp: + value: float + + class def __of__(cls, value: int | float) -> Dp: + return Dp(float(value)) +"#; + +/// imports `Dp` through the package only, and converts a literal at a call +/// argument, an annotated assignment and a return — every conversion site +/// reaches `Dp` the same way +const MAIN: &str = r#" +from ui import Dp + +def pad(amount: Dp) -> float: + return amount.value + +def default() -> Dp: + return 4 + +gap: Dp = 2 +assert pad(8) == 8.0, pad(8) +assert gap.value == 2.0, gap +assert default().value == 4.0, default() +print("ok") +"#; + +/// the project's files, in the layout the checker resolves them under +const FILES: &[(&str, &str)] = &[ + ("/ui/__init__.by", PACKAGE_INIT), + ("/ui/geometry.by", GEOMETRY), + ("/main.by", MAIN), +]; + +fn build_db() -> TestDb { + let mut db = TestDb::new(ProjectMetadata::new( + ruff_python_ast::name::Name::new_static(""), + SystemPathBuf::from("/"), + )); + db.init_program().expect("program init failed"); + for (path, source) in FILES { + db.write_file(path, source).expect("write file failed"); + } + db +} + +/// transpile every file of the project through the typed pipeline into a fresh +/// directory under the cargo temp dir, keeping the package layout +fn build_case(case: &str) -> PathBuf { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(case); + // a stale directory from an earlier run would mask a transpile failure + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("ui")).expect("create case dir"); + + let db = build_db(); + let config = Config { + min_version: PythonVersion::PY313, + ..Config::default() + }; + for (path, _) in FILES { + let file = system_path_to_file(&db, path).expect("file not in db"); + let transpiled = transpile_typed(&db, file, &config, None) + .unwrap_or_else(|error| panic!("transpile of {path} should succeed: {error}")); + let relative = path.trim_start_matches('/').replace(".by", ".py"); + fs::write(dir.join(relative), transpiled).expect("write module"); + } + dir +} + +#[test] +fn conversion_through_a_re_export_resolves_at_runtime() { + let Some(python) = python() else { + eprintln!("skipping re-export conversion runtime test: no `python3` interpreter found"); + return; + }; + let dir = build_case("reexport_conversion"); + + let output = Command::new(&python) + .arg("main.py") + .current_dir(&dir) + .output() + .expect("failed to spawn python"); + + assert!( + output.status.success(), + "transpiled program failed on {python}:\n--- stdout ---\n{}\n--- stderr ---\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "ok"); +} diff --git a/crates/ruff_python_ast/src/nodes.rs b/crates/ruff_python_ast/src/nodes.rs index 7ab7016862..a6adbd4aad 100644 --- a/crates/ruff_python_ast/src/nodes.rs +++ b/crates/ruff_python_ast/src/nodes.rs @@ -52,6 +52,26 @@ impl StmtFunctionDef { expression => expression, }) } + + /// basedpython: the call a trailing lambda block hangs off, when the block + /// is written as a plain parenthesized call (`f(2):`) — the one whose + /// written arguments and the block are bound together. `None` for a bare + /// callee (`f:`) and for the call forms [`trailing_lambda_callee`] treats + /// as a whole (a `cast`, a string tag) + /// + /// [`trailing_lambda_callee`]: Self::trailing_lambda_callee + pub fn trailing_lambda_call(&self) -> Option<&ExprCall> { + let callee = self.trailing_lambda_callee()?; + let decorator = self.decorator_list.first()?; + let expression = match &decorator.expression { + Expr::Await(await_expr) => await_expr.value.as_ref(), + expression => expression, + }; + match expression { + Expr::Call(call) if std::ptr::eq(callee, call.func.as_ref()) => Some(call), + _ => None, + } + } } impl crate::ExprStatement { diff --git a/crates/ruff_python_parser/src/parser/statement.rs b/crates/ruff_python_parser/src/parser/statement.rs index 50e36672b5..037c8f04ba 100644 --- a/crates/ruff_python_parser/src/parser/statement.rs +++ b/crates/ruff_python_parser/src/parser/statement.rs @@ -8250,11 +8250,43 @@ impl<'src> Parser<'src> { ); } } + // one exception: the last parameter may be the callback a trailing block + // (`f(...):`) fills, which the call passes by keyword — so a component + // can declare both a `context` parameter and a content block: + // `def Card(title: str, context theme: Theme, once content: () -> None)` + // + // A callable annotation alone is not enough to earn the exemption. An + // ordinary callable parameter *can* take a positional argument, and + // exempting it would let `Card("x", handler)` bind `handler` to the + // `context` parameter instead — the very thing this rule exists to stop. + // The `local` / `once` modifier is what marks a parameter as a borrowed + // callback rather than a value the caller hands over, so that is what is + // required here. Anything else after a `context` parameter can still be + // written keyword-only, after a bare `*`. + let trailing_callback_index = parameters + .args + .last() + .filter(|param| { + matches!( + param.parameter.annotation.as_deref(), + Some(ruff_python_ast::Expr::CallableType(_)) + ) && { + // `local` / `once` on a `def` parameter carry no AST field: + // they live in the span between the parameter's start and + // its name (see `parameter_modifiers`) + let modifiers = ruff_python_ast::helpers::parameter_modifiers( + self.source, + ¶m.parameter, + ); + modifiers.local || modifiers.once + } + }) + .map(|_| parameters.args.len() - 1); let mut seen_context_param = false; - for param in ¶meters.args { + for (index, param) in parameters.args.iter().enumerate() { if param.parameter.is_context { seen_context_param = true; - } else if seen_context_param { + } else if seen_context_param && trailing_callback_index != Some(index) { self.add_error( ParseErrorType::OtherError( "parameter after a `context` parameter must also be `context`".to_string(), diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index 1c12ffbbc7..7684c02551 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -8,7 +8,7 @@ Default level: error · Added in 0.0.64 · Related issues · -View source +View source @@ -44,7 +44,7 @@ class Base(ABC): Default level: error · Added in 0.0.13 · Related issues · -View source +View source @@ -90,7 +90,7 @@ class Derived(Base): # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -123,7 +123,7 @@ f(1, b=s1) # ok — explicit Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.39 · Related issues · -View source +View source @@ -159,7 +159,7 @@ report(Celsius()) # error: two conversions apply Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.3 · Related issues · -View source +View source @@ -194,7 +194,7 @@ extension list: Default level: warn · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -258,7 +258,7 @@ class SubProto(BaseProto, Protocol): Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -340,7 +340,7 @@ value = unknown # ty: ignore[unresolved-reference] Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -383,7 +383,7 @@ a4 = True + 1 # ok — a boolean used as a boolean Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -435,7 +435,7 @@ Foo.method() # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -463,7 +463,7 @@ Calling a non-callable object will raise a `TypeError` at runtime. Default level: error · Added in 0.0.7 · Related issues · -View source +View source @@ -492,13 +492,108 @@ def f(x: object): x() # error ``` +## `composable-outside-composition` + + +Default level: error · basedpython only, so absent under ty-compatible · +Added in 0.0.1-alpha.40 · +Related issues · +View source + + + +**What it does** + + +Checks for a call to a `basedpython_ui` composable (a function decorated `@composable`) or to one +of the framework's widget builders (`Text`, `Button`, `Column`, …) from somewhere that is not a +composition: a function that is not itself a composable, a handler block, a lambda or a nested +`def`. A composable's body, the `once` content blocks and `local` blocks written in it, and the +`root` block of `run_app` / `compose_test` are compositions. + +**Why is this bad?** + + +A composable opens a scope in the composition being built and a builder emits into it; neither has +anything to build into outside of one. The runtime raises `CompositionError` at the call; this +check reports it at the source. + +**Examples** + + +```by +from basedpython_ui import composable, run_app, Button, Text + +@composable +def Counter(): ... + +def helper(): + Counter() # error: `helper` is not a composable + +@composable +def App(): + Button("x"): + Text("clicked") # error: a handler runs after composition + +def main(): + run_app("app"): + App() # ok: the root of the composition +``` + +## `conditional-slot` + + +Default level: warn · basedpython only, so absent under ty-compatible · +Added in 0.0.1-alpha.40 · +Related issues · +View source + + + +**What it does** + + +Checks for a `basedpython_ui` slot — `state`, `state_list`, `state_dict`, `derived`, `remember`, +`launched_effect`, `disposable_effect`, `side_effect` — created under a condition in a composable: +inside an `if`, `for`, `while`, `try` or `match`, inside a comprehension, or inside a block that is +not a `once` content block (a handler block, a lambda, a nested `def`). + +**Why is this bad?** + + +A slot lives as long as its enclosing composition scope and is identified by its call site, so a +conditional slot is created when the condition first holds and disposed — its state lost, its +effect cancelled — as soon as it stops holding. That is rarely what the code means: state that +should outlive a condition belongs above it, and a slot created from a handler has no scope to live +in at all. The runtime keys slots by call site, so this is safe at runtime; the check makes the +lifetime visible. + +**Examples** + + +```by +from basedpython_ui import composable, state, Text + +@composable +def Profile(show: bool): + if show: + let clicks = state(0) # warning: created and disposed as `show` changes + Text(f"{clicks.value}") + +@composable +def Fixed(show: bool): + let clicks = state(0) # ok: lives as long as `Fixed` + if show: + Text(f"{clicks.value}") +``` + ## `conflicting-declarations` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -531,7 +626,7 @@ a = 1 # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -559,13 +654,59 @@ class B(metaclass=M2): ... class C(A, B): ... # error ``` +## `content-block-control-flow` + + +Default level: error · basedpython only, so absent under ty-compatible · +Added in 0.0.1-alpha.40 · +Related issues · +View source + + + +**What it does** + + +Checks for a `return` inside a `once` content block that is itself written inside another +trailing-lambda block. + +**Why is this bad?** + + +A `once` block runs exactly once, inline, so a `return` inside it is allowed to leave the enclosing +scope — but only one level: the language propagates a block's `return` to the scope the block is +written in. When that scope is itself a block, the `return` leaves the inner block and stops there; +the enclosing function keeps running, and the returned value is silently discarded. + +(A `break` or `continue` inside any block is already rejected as `break` outside loop: a block is +its own function.) + +**Examples** + + +```by +def Column(once content: () -> None): + content() + +def Row(once content: () -> None): + content() + +def App(done: bool) -> int: + Column: + Row: + if done: + return 1 # error: [content-block-control-flow] + return 2 # ok: one level, leaves `App` + return 0 +``` + ## `cyclic-class-definition` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -599,7 +740,7 @@ class B(A): ... # error Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -644,7 +785,7 @@ type Tree = int | list[Tree] # valid recursive alias Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -680,7 +821,7 @@ class Example: Default level: warn · Added in 0.0.1-alpha.16 · Related issues · -View source +View source @@ -719,7 +860,7 @@ old_func() # error: [deprecated] Default level: ignore · Added in 0.0.78 · Related issues · -View source +View source @@ -891,7 +1032,7 @@ soundness checks from their type checker, and it may have false positives in som Default level: error · Level under ty-compatible: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -923,7 +1064,7 @@ This rule is currently disabled by default because of the number of false positi Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -954,7 +1095,7 @@ class B(A, A): ... # error Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -994,7 +1135,7 @@ class A: # error Default level: ignore · Added in 0.0.73 · Related issues · -View source +View source @@ -1105,7 +1246,7 @@ Python code. Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -1154,7 +1295,7 @@ def bar() -> str: # error: [empty-body] Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -1215,7 +1356,7 @@ def h(x: object): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.3 · Related issues · -View source +View source @@ -1317,7 +1458,7 @@ def foo() -> "intt\b": ... # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1356,7 +1497,7 @@ def f(local fn: () -> None): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1397,7 +1538,7 @@ for x in [1, 2, 3]: Default level: warn · Added in 0.0.50 · Related issues · -View source +View source @@ -1437,7 +1578,7 @@ def g(value: ~A) -> None: ... # error: [experimental-syntax] Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -1471,7 +1612,7 @@ def my_function() -> int: Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.40 · Related issues · -View source +View source @@ -1506,7 +1647,7 @@ let a = 1 Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -1623,7 +1764,7 @@ def test() -> "Literal[5]": Default level: ignore · basedpython only, so absent under ty-compatible · Added in 0.0.72 · Related issues · -View source +View source @@ -1676,7 +1817,7 @@ unpacking — is not a declaration, and is never reported. Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.68 · Related issues · -View source +View source @@ -1751,7 +1892,7 @@ print(Labelled) # warning: prints `` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1787,7 +1928,7 @@ class C(A, B): ... # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1816,7 +1957,7 @@ t[3] # error Default level: warn · Added in 0.0.1-alpha.33 · Related issues · -View source +View source @@ -1853,7 +1994,7 @@ class MyClass: ... Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.79 · Related issues · -View source +View source @@ -1889,7 +2030,7 @@ class Point: Default level: error · Added in 0.0.1-alpha.12 · Related issues · -View source +View source @@ -1984,7 +2125,7 @@ will produce instances with an atypical memory layout. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2016,7 +2157,7 @@ func("foo") # error: [invalid-argument-type] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2046,7 +2187,7 @@ a: int = "" # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2127,7 +2268,7 @@ box.value = 1 # okay Default level: error · Added in 0.0.33 · Related issues · -View source +View source @@ -2172,7 +2313,7 @@ class Sub(Base): Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -2214,7 +2355,7 @@ asyncio.run(main()) Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2241,7 +2382,7 @@ class A(42): ... # error: [invalid-base] Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.79 · Related issues · -View source +View source @@ -2279,7 +2420,7 @@ build: # error: `build` is an experimental feature, and is off Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.5 · Related issues · -View source +View source @@ -2316,7 +2457,7 @@ extension str(A): # error: `str` does not answer every member of `A` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2345,7 +2486,7 @@ with 1: # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.39 · Related issues · -View source +View source @@ -2378,7 +2519,7 @@ class Fahrenheit: Default level: error · Added in 0.0.12 · Related issues · -View source +View source @@ -2431,7 +2572,7 @@ See: Default level: error · Added in 0.0.13 · Related issues · -View source +View source @@ -2467,7 +2608,7 @@ class A: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2499,7 +2640,7 @@ a: str # error Default level: warn · Added in 0.0.20 · Related issues · -View source +View source @@ -2555,7 +2696,7 @@ class Pet(Enum): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2620,7 +2761,7 @@ This rule corresponds to Ruff's Default level: error · Added in 0.0.1-alpha.28 · Related issues · -View source +View source @@ -2674,7 +2815,7 @@ class D(A): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.3 · Related issues · -View source +View source @@ -2707,7 +2848,7 @@ extension list[T: int]: # error: `list` declares no type parameter `T` Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.4 · Related issues · -View source +View source @@ -2736,7 +2877,7 @@ Author.objects.filter(name__startswith=1) # error: lookup wants `str` Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.36 · Related issues · -View source +View source @@ -2772,7 +2913,7 @@ def test_user(user: int) -> None: # error: fixture provides `str` Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.68 · Related issues · -View source +View source @@ -2820,7 +2961,7 @@ f"{'name':>10}" # ok Default level: error · Added in 0.0.1-alpha.35 · Related issues · -View source +View source @@ -2870,7 +3011,7 @@ class NonFrozenChild(FrozenBase): # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2925,7 +3066,7 @@ class E(Generic[V]): Default level: error · Added in 0.0.12 · Related issues · -View source +View source @@ -3020,7 +3161,7 @@ a = 20 / 0 # type: ignore Default level: error · Added in 0.0.1-alpha.17 · Related issues · -View source +View source @@ -3067,7 +3208,7 @@ carol = Person(name="Carol", aeg=25) # typo! Default level: warn · Added in 0.0.15 · Related issues · -View source +View source @@ -3128,7 +3269,7 @@ def f(x, y, /): # Python 3.8+ syntax Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3168,7 +3309,7 @@ def f(t: TypeVar("U")): ... # ty: ignore[invalid-type-form] Default level: error · Added in 0.0.18 · Related issues · -View source +View source @@ -3219,7 +3360,7 @@ match object(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3253,7 +3394,7 @@ class B(metaclass=42): ... # error Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -3370,7 +3511,7 @@ Correct use of `@override` is enforced by ty's [`invalid-explicit-override`](#in Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.72 · Related issues · -View source +View source @@ -3413,7 +3554,7 @@ implements Backend # error: `Backend` is not a protocol Default level: error · Added in 0.0.72 · Related issues · -View source +View source @@ -3451,7 +3592,7 @@ from module import missing # error Default level: error · Added in 0.0.1-alpha.19 · Related issues · -View source +View source @@ -3516,7 +3657,7 @@ TypeError: typing.ClassVar[int] is not valid as type argument Default level: warn · Added in 0.0.31 · Related issues · -View source +View source @@ -3562,7 +3703,7 @@ admin[0] # "Alice" Default level: error · Added in 0.0.1-alpha.27 · Related issues · -View source +View source @@ -3600,7 +3741,7 @@ Baz = NewType("Baz", int | str) # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3657,7 +3798,7 @@ def foo(x: int) -> int: ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3685,7 +3826,7 @@ def f(a: int = ""): ... # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.36 · Related issues · -View source +View source @@ -3718,7 +3859,7 @@ def test_add(a: int, b: int) -> None: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3754,7 +3895,7 @@ P2 = ParamSpec() # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3806,7 +3947,7 @@ Declare the type variable with `TypeVar("T", covariant=True)` instead. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -3877,7 +4018,7 @@ def g(): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.37 · Related issues · -View source +View source @@ -3905,7 +4046,7 @@ def f() raises int: # error: `int` is not an exception Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.36 · Related issues · -View source +View source @@ -3938,7 +4079,7 @@ if m := re.match("(a)(b)", "ab"): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -3970,7 +4111,7 @@ type Alias[reified T] = list[T] # error: an alias's parameters are erased Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4116,7 +4257,7 @@ def detail(request, pk: int): ... # ok Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.79 · Related issues · -View source +View source @@ -4155,7 +4296,7 @@ import "data/missing.json" as missing Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4263,7 +4404,7 @@ class C: ... Default level: error · Added in 0.0.10 · Related issues · -View source +View source @@ -4314,7 +4455,7 @@ class MyClass: Default level: error · Added in 0.0.1-alpha.6 · Related issues · -View source +View source @@ -4360,7 +4501,7 @@ InvalidAlias = TypeAliasType("InvalidAlias", list[T], type_params=(list[T],)) # Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -4426,7 +4567,7 @@ Bar[int] # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4459,7 +4600,7 @@ TYPE_CHECKING = "" # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4494,7 +4635,7 @@ b: Annotated[int] # error Default level: error · Added in 0.0.1-alpha.11 · Related issues · -View source +View source @@ -4551,7 +4692,7 @@ class C: Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -4617,7 +4758,7 @@ class Owner[U]: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -4674,7 +4815,7 @@ V = TypeVar("V", list[int], int) # valid constrained Type Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -4716,7 +4857,7 @@ U = TypeVar("U", int, str, default=bytes) # error: [invalid-type-variable-defau Default level: error · Added in 0.0.28 · Related issues · -View source +View source @@ -4752,7 +4893,7 @@ class Child(Base): Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -4793,7 +4934,7 @@ def f(options: dict[str, object]): Default level: error · Added in 0.0.9 · Related issues · -View source +View source @@ -4827,7 +4968,7 @@ class Foo(TypedDict): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -4868,7 +5009,7 @@ type Alias[out T] = list[T] # error: `list` is invariant Default level: error · Added in 0.0.25 · Related issues · -View source +View source @@ -4902,7 +5043,7 @@ def gen() -> Iterator[int]: Default level: error · Added in 0.0.14 · Related issues · -View source +View source @@ -4968,7 +5109,7 @@ def h(arg2: type): Default level: error · Added in 0.0.15 · Related issues · -View source +View source @@ -5017,7 +5158,7 @@ def g(arg: object): Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.36 · Related issues · -View source +View source @@ -5048,7 +5189,7 @@ def f(s: str): Default level: warn · Added in 0.0.30 · Related issues · -View source +View source @@ -5090,7 +5231,7 @@ Movie = TypedDict("Film", {"title": str}) # error: [mismatched-type-name] Default level: warn · Added in 0.0.1-alpha.39 · Related issues · -View source +View source @@ -5158,7 +5299,7 @@ and nothing is reported. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5189,7 +5330,7 @@ func() # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -5220,7 +5361,7 @@ f(1) # ok — `s` is passed implicitly Default level: ignore · Preview (since 0.0.76) · Related issues · -View source +View source @@ -5309,7 +5450,7 @@ Add `urllib3` to `project.dependencies` if your code imports it directly. Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.4 · Related issues · -View source +View source @@ -5337,7 +5478,7 @@ from django.db import models # warning: install `django-stubs` for precise type Default level: error · Level under ty-compatible: ignore · Added in 0.0.41 · Related issues · -View source +View source @@ -5398,7 +5539,7 @@ class ExplicitChild(Parent): Default level: error · Added in 0.0.75 · Related issues · -View source +View source @@ -5485,7 +5626,7 @@ class Item: Default level: error · Level under ty-compatible: ignore · Added in 0.0.45 · Related issues · -View source +View source @@ -5523,7 +5664,7 @@ def handle(m: re.Match[str]) -> str: Default level: error · Added in 0.0.1-alpha.20 · Related issues · -View source +View source @@ -5556,13 +5697,60 @@ alice: Person = {"name": "Alice"} # error alice["age"] # KeyError ``` +## `mutable-state-value` + + +Default level: error · basedpython only, so absent under ty-compatible · +Added in 0.0.1-alpha.40 · +Related issues · +View source + + + +**What it does** + + +Checks for a value that is not deeply immutable being placed in `basedpython_ui` state: the +initial value of `state(...)` / `State(...)`, the elements of `state_list(...)` / `StateList(...)`, +the value computed by `derived(...)` / `remember(...)`, a value assigned to a `State`'s `.value`, +appended to or inserted into a `StateList`, stored into a `StateDict`, or given to `provide(...)`. + +**Why is this bad?** + + +A `State` notifies its readers when it is *assigned*. A change made *inside* the held value — +`items.append(1)` on a held `list`, a field written on a held plain class — notifies nobody, so the +ui keeps showing the old value until something unrelated recomposes it. The runtime refuses such a +value with a `TypeError`; this check reports it at the source. + +A value is deeply immutable when nothing reachable from it can change: the scalars, enum members, a +`tuple` or `frozenset` of immutable elements, a `frozen data class` or `NamedTuple` of immutable +fields, a type object, a callable, or one of the framework's own observables (`State`, `StateList`, +`StateDict`, `Derived`, `Ambient`). + +**Examples** + + +```by +from basedpython_ui import composable, state, state_list + +frozen data class Todo: + title: str + +@composable +def App(): + let items = state([1, 2]) # error: `list[int]` cannot be held in state + let names = state((1, 2)) # ok: a tuple of immutables + let todos = state_list([Todo("a")]) # ok: an observable list of frozen records +``` + ## `narrowing-guard-as-value` Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5596,7 +5784,7 @@ def f(a: int | None): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5634,7 +5822,7 @@ func("string") # error: [no-matching-overload] Default level: error · Added in 0.0.30 · Related issues · -View source +View source @@ -5671,7 +5859,7 @@ class Sub(Super): ... # error: [non-callable-init-subclass] Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.39 · Related issues · -View source +View source @@ -5702,7 +5890,7 @@ def f(x: int | str) -> int: Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -5731,7 +5919,7 @@ def f(a: object): Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -5775,7 +5963,7 @@ def g(o: object, shape: Shape): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5804,7 +5992,7 @@ for i in 34: # error Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5832,7 +6020,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5861,7 +6049,7 @@ def f(once done: () -> None): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -5889,7 +6077,7 @@ def f(once done: () -> None): Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.61 · Related issues · -View source +View source @@ -5927,7 +6115,7 @@ def f(x: int?): Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -5982,7 +6170,7 @@ def g(name: str | None): Default level: error · Added in 0.0.1-alpha.29 · Related issues · -View source +View source @@ -6019,7 +6207,7 @@ class B(A): Default level: error · Added in 0.0.16 · Related issues · -View source +View source @@ -6056,7 +6244,7 @@ class B(A): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.38 · Related issues · -View source +View source @@ -6099,7 +6287,7 @@ def main(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -6130,7 +6318,7 @@ f(1, x=2) # error Default level: error · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -6161,7 +6349,7 @@ f(x=1) # error Default level: error · Level under ty-compatible: ignore · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -6199,7 +6387,7 @@ A.c # error Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -6237,7 +6425,7 @@ A()[0] # error Default level: error · Level under ty-compatible: ignore · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -6281,7 +6469,7 @@ from module import a # error Default level: warn · Added in 0.0.23 · Related issues · -View source +View source @@ -6313,7 +6501,7 @@ html.parser # error Default level: error · Level under ty-compatible: ignore · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -6349,7 +6537,7 @@ print(x) # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.79 · Related issues · -View source +View source @@ -6388,7 +6576,7 @@ Id("x") # error: `Id`'s constructor is private Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -6420,7 +6608,7 @@ from helpers import Key # error: `Key` is private to `helpers` Default level: warn · Added in 0.0.60 · Related issues · -View source +View source @@ -6495,7 +6683,7 @@ def test() -> "int": Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -6537,7 +6725,7 @@ def g(a: bool | None): Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -6572,7 +6760,7 @@ cast(int, f()) # error Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -6626,7 +6814,7 @@ if sys.version_info >= (3, 12): # ok — artificially constant Default level: warn · Added in 0.0.18 · Related issues · -View source +View source @@ -6664,7 +6852,7 @@ class C: Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -6719,7 +6907,7 @@ class Sub(Base): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.62 · Related issues · -View source +View source @@ -6768,7 +6956,7 @@ def f(value: int | str) -> int: Default level: error · Added in 0.0.71 · Related issues · -View source +View source @@ -6832,7 +7020,7 @@ def g(values: tuple[int, ...]) -> None: Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.3 · Related issues · -View source +View source @@ -6865,7 +7053,7 @@ class C: Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.72 · Related issues · -View source +View source @@ -6901,7 +7089,7 @@ class C[T]: Default level: warn · Added in 0.0.71 · Related issues · -View source +View source @@ -6944,7 +7132,7 @@ def build(t: Tag) -> None: Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -6982,13 +7170,106 @@ class Outer[T]: - [Typing spec: Generics](https://typing.python.org/en/latest/spec/generics.html#introduction) +## `silent-mutation` + + +Default level: error · basedpython only, so absent under ty-compatible · +Added in 0.0.1-alpha.40 · +Related issues · +View source + + + +**What it does** + + +Checks for an in-place mutation written inside a `basedpython_ui` composable — in its body, in a +`once` content block written in it, or in a handler block, lambda or nested `def` written in it: a +mutating method call (`append`, `extend`, `insert`, `pop`, `remove`, `clear`, `sort`, `reverse`, +`update`, `setdefault`, `popitem`, `add`, `discard`, …) on a builtin mutable container, an in-place +operator (`+=`, `|=`, …) on one, a subscript store or delete on one, or an attribute store on an +instance of a class that is not frozen and not an observable. + +A container the same body creates itself — bound to a display, a comprehension or a constructor +call — is a fresh local, and mutating it is allowed. + +**Why is this bad?** + + +A composition re-runs when an observable it read is written. A `list` or a plain object is not +observable: mutating it in place changes what the ui *should* show without telling the runtime, +so the change is not seen until something unrelated recomposes the scope. Mutate a `StateList` / +`StateDict`, or rebuild an immutable value and assign it to a `State`, and the change notifies. + +**Examples** + + +```by +from basedpython_ui import composable, state_list, Button + +@composable +def TodoList(items: list[str]): + Button("add"): + items.append("x") # error: mutates `list[str]` in place + +@composable +def Observed(): + let items = state_list(["a"]) + Button("add"): + items.append("x") # ok: a `StateList` write notifies its readers +``` + +## `state-write-in-composition` + + +Default level: error · basedpython only, so absent under ty-compatible · +Added in 0.0.1-alpha.40 · +Related issues · +View source + + + +**What it does** + + +Checks for a write to `basedpython_ui` state made while a composition is running: in a composable's +body or in a `once` content block written in it, an assignment to a `State`'s `.value` (plain or +augmented), a call to `State.set` / `State.update`, or a mutating call, subscript store or delete +on a `StateList` / `StateDict`. + +Writes made from a handler block, a lambda, a nested `def` or an effect block are not in +composition: those run later, in response to an event, and are the right place for them. + +**Why is this bad?** + + +Composition is a pure description of the ui for the current state. A write made while composing +invalidates the very scope being composed (or one already composed this frame), which would loop +or show a frame that is half old and half new. The runtime raises `CompositionError` before +applying such a write; this check reports it at the source. + +**Examples** + + +```by +from basedpython_ui import composable, state, Button, Text + +@composable +def Counter(): + let count = state(0) + count.value = 1 # error: written while `Counter` is composing + Text(f"{count.value}") + Button("+"): + count.value += 1 # ok: a handler runs after composition +``` + ## `static-assert-error` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7022,7 +7303,7 @@ static_assert(int(2.0 * 3.0) == 6) # error Default level: warn · Added in 0.0.39 · Related issues · -View source +View source @@ -7074,7 +7355,7 @@ limitation. Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7108,7 +7389,7 @@ class B(A): ... # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7140,7 +7421,7 @@ class Circle(Shape): ... # error: `Shape` is sealed in another workspace Default level: error · Added in 0.0.1-alpha.30 · Related issues · -View source +View source @@ -7249,7 +7530,7 @@ class Book: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7279,7 +7560,7 @@ f("foo") # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7318,7 +7599,7 @@ def find(items: list[int]) -> int: Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7365,7 +7646,7 @@ g: Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7400,7 +7681,7 @@ f: # error: the block returns `None`, not `str` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7439,7 +7720,7 @@ def _(x: int): Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.4 · Related issues · -View source +View source @@ -7470,7 +7751,7 @@ class User(BaseModel): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7529,7 +7810,7 @@ class A: Default level: error · Added in 0.0.20 · Related issues · -View source +View source @@ -7602,7 +7883,7 @@ the project registers with `@register.simple_block_tag`. Default level: warn · Added in 0.0.1-alpha.39 · Related issues · -View source +View source @@ -7668,7 +7949,7 @@ what the projects depending on it read. Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.37 · Related issues · -View source +View source @@ -7697,7 +7978,7 @@ def f() raises TypeError: Default level: warn · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7726,7 +8007,7 @@ reveal_type(1) # revealed: Literal[1] Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.37 · Related issues · -View source +View source @@ -7756,7 +8037,7 @@ def main(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -7787,7 +8068,7 @@ f(x=1, y=2) # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.36 · Related issues · -View source +View source @@ -7998,7 +8279,7 @@ page does not render at all. Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.72 · Related issues · -View source +View source @@ -8026,13 +8307,72 @@ protocol Backend: implements Backend # error: this module does not answer `Backend` ``` +## `unobservable-dependency` + + +Default level: error · basedpython only, so absent under ty-compatible · +Added in 0.0.1-alpha.40 · +Related issues · +View source + + + +**What it does** + + +Checks for a read, made while a `basedpython_ui` composition runs, of a value it cannot observe: +a load of a parameter of the composable (a `context` parameter included), of a module global, or +of a local captured from an enclosing function, whose type is neither deeply immutable nor one of +the framework's observables (`State`, `StateList`, `StateDict`, `Derived`, `Ambient`). + +What runs while composing is the composable's body, the `once` content blocks and `local` blocks +written in it, and the lambda given to `derived(...)` / `remember(...)`. A handler block, a lambda, +a nested `def` or an effect block runs later, so a read there is not a dependency of the +composition. A name the composition binds itself — a local of the body or of a block, a `for` +target, a comprehension variable — is this run's own value and is not reported, whatever its +type; a `dynamic` value is exempt, as everywhere. A read-only view (`list[out str]`) is reported +like a plain `list`: it restricts this reader, not the other holders of the list. + +**Why is this bad?** + + +A mutation of non-observable data is never a trigger: an immutable value cannot change, an +observable notifies its readers when it does, and a mutable value changes without telling anyone. +A composition that reads a mutable parameter or global therefore shows a stale ui after any change +to it — wherever that change is made: another module, a `.py` caller, a `dynamic` value, a +callback. [`silent-mutation`](#silent-mutation) reports the writes it can see; this check is what makes the guarantee +general, by keeping a composition from depending on such a value in the first place. + +Hold the value in state (`state_list`, `state_dict`), pass an immutable value (a `tuple`, a +`frozen data class`), or read it only in a handler. + +**Examples** + + +```by +from basedpython_ui import composable, state_list, Text + +@composable +def Names(items: list[str]): + Text(str(len(items))) # error: read while `Names` composes, but nothing observes it + +@composable +def Frozen(items: tuple[str, ...]): + Text(str(len(items))) # ok: a tuple cannot change + +@composable +def Held(): + let items = state_list(["a"]) + Text(str(len(items))) # ok: a `StateList` notifies its readers +``` + ## `unresolved-attribute` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -8065,7 +8405,7 @@ A().foo # error Default level: warn · Added in 0.0.1-alpha.15 · Related issues · -View source +View source @@ -8140,7 +8480,7 @@ def g(): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -8168,7 +8508,7 @@ import foo # error Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -8198,7 +8538,7 @@ def check(value: int | None) -> asserts values: # error: `values` is nothing Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -8316,7 +8656,7 @@ is one whose template set cannot be established. Default level: ignore · Added in 0.0.73 · Related issues · -View source +View source @@ -8444,7 +8784,7 @@ Python code. Default level: error · Added in 0.0.71 · Related issues · -View source +View source @@ -8485,7 +8825,7 @@ def f(a: object, b: int, c: Any): Default level: ignore · Added in 0.0.70 · Related issues · -View source +View source @@ -8630,7 +8970,7 @@ Python code. Default level: ignore · Added in 0.0.70 · Related issues · -View source +View source @@ -8777,7 +9117,7 @@ generator boundaries. Default level: error · basedpython only, so absent under ty-compatible · Added in 0.0.1-alpha.3 · Related issues · -View source +View source @@ -8821,13 +9161,60 @@ a: A[int] = A() # ok — transpiles to A[int]() A() # error: nothing says which specialization this is ``` +## `unstable-parameter` + + +Default level: warn · basedpython only, so absent under ty-compatible · +Added in 0.0.1-alpha.40 · +Related issues · +View source + + + +**What it does** + + +Checks for a parameter of a `basedpython_ui` composable whose declared type is not *stable*: +not deeply immutable, and not a read-only view of immutable elements (`list[out int]`). + +**Why is this bad?** + + +A composable is skipped on recomposition only when every argument is stable and equal to the last +one. A `list`, `dict`, `set` or non-frozen class can be changed behind the composable's back, so the +runtime cannot compare it and never skips the scope: the composable re-runs on every recomposition +of its parent, however little changed. Prefer an immutable spelling (`tuple[int, ...]`, a +`frozen data class`) or an observable (`state_list`, `state_dict`). + +This is a warning about skipping alone: a mutable parameter that only a handler touches never +triggers a re-render, but does not make the composition stale on its own. Reading one while +composing is what does that, and is reported as an [`unobservable-dependency`](#unobservable-dependency). A read-only view +(`list[out int]`) is stable for skipping — the runtime compares it structurally at recomposition — +but is still unobservable when read, so it is not the spelling to reach for. + +**Examples** + + +```by +from basedpython_ui import composable, StateList + +@composable +def TodoList(items: list[int]): ... # warning: never skipped + +@composable +def Skippable(items: tuple[int, ...]): ... # ok + +@composable +def Observed(items: StateList[int]): ... # ok: an observable handle +``` + ## `unsupported-base` Default level: warn · Added in 0.0.1-alpha.7 · Related issues · -View source +View source @@ -8873,7 +9260,7 @@ class D(C): ... # error: [unsupported-base] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -8922,7 +9309,7 @@ b1 < b2 < b1 # error Default level: warn · Level under ty-compatible: ignore · Added in 0.0.12 · Related issues · -View source +View source @@ -8967,7 +9354,7 @@ def factory(base: type[Base]) -> type: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -8999,7 +9386,7 @@ A() + A() # error Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.79 · Related issues · -View source +View source @@ -9042,7 +9429,7 @@ reveal_type(project.root) # revealed: "." Default level: warn · Added in 0.0.21 · Related issues · -View source +View source @@ -9121,7 +9508,7 @@ to `false` to prevent this rule from reporting unused `type: ignore` comments. Default level: warn · basedpython only, so absent under ty-compatible · Added in 0.0.71 · Related issues · -View source +View source @@ -9207,7 +9594,7 @@ to `false`. Default level: warn · Added in 0.0.1-alpha.22 · Related issues · -View source +View source @@ -9286,7 +9673,7 @@ def foo(x: int | str) -> int | str: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty/tests/mdtest_divergence.rs b/crates/ty/tests/mdtest_divergence.rs index a3fefb8a10..426427624a 100644 --- a/crates/ty/tests/mdtest_divergence.rs +++ b/crates/ty/tests/mdtest_divergence.rs @@ -241,6 +241,16 @@ fn clean_mdtest_blocks_run() { .output() .is_ok_and(|o| o.status.success()); + // the basedpython-ui suite needs the framework installed to execute: its + // blocks import `basedpython_ui`, and the mocks the mdtests declare live in + // the checker's own file system rather than on disk. skipped exactly like + // the other frameworks; run them locally against an interpreter that has + // basedpython_ui to enforce the contract + let has_basedpython_ui = Command::new(&python) + .args(["-c", "import basedpython_ui"]) + .output() + .is_ok_and(|o| o.status.success()); + // `frozendict` is a 3.15 builtin, so on the 3.13 floor this harness targets // its blocks cannot run at all. skipped exactly like a missing third-party // dependency; run them locally against a 3.15 interpreter to enforce the @@ -271,6 +281,27 @@ fn clean_mdtest_blocks_run() { // work can be spread across a pool of workers rather than run serially. each // block is an independent `by transpile` + python subprocess pair, so the // harness is dominated by process spawn latency and parallelises cleanly. + // + // a block naming a module this interpreter does not have is dropped here rather + // than after transpiling it. the checks below still have to run, because the + // transpiler can *introduce* one of these — lowering `float` pulls in + // `ty_extensions` — but it never drops an import the source wrote, so a source + // mention is a subset of what those checks would catch. transpiling one only to + // throw the result away costs a `by` spawn, which reads typeshed at startup and + // is the single most expensive thing this harness does per block + let unavailable: Vec<&str> = [ + (!has_typing_extensions).then_some("typing_extensions"), + (!has_frozendict).then_some("frozendict"), + (!has_pydantic).then_some("pydantic"), + (!has_sqlalchemy).then_some("sqlalchemy"), + (!has_pytest).then_some("pytest"), + (!has_basedpython_ui).then_some("basedpython_ui"), + Some("ty_extensions"), + ] + .into_iter() + .flatten() + .collect(); + let mut items: Vec<(String, usize, String)> = Vec::new(); for file in &files { let name = file.file_name().unwrap().to_string_lossy().into_owned(); @@ -279,6 +310,9 @@ fn clean_mdtest_blocks_run() { if has_expected_diagnostics(&block) || multi_file { continue; } + if unavailable.iter().any(|module| block.contains(module)) { + continue; + } items.push((name.clone(), i, block)); } } @@ -322,6 +356,16 @@ fn clean_mdtest_blocks_run() { continue; } }; + // `ty_extensions` is a checker-only surface: the predicates + // a block asserts with it (`static_assert`, + // `is_deeply_immutable`) are answered during checking and + // there is no runtime module behind them, on any + // interpreter. such a block has no runtime behaviour to + // diverge, so it is skipped outright rather than gated on a + // dependency that could never be installed + if transpiled.contains("ty_extensions") { + continue; + } if !has_typing_extensions && transpiled.contains("typing_extensions") { continue; } @@ -337,6 +381,9 @@ fn clean_mdtest_blocks_run() { if !has_pytest && transpiled.contains("pytest") { continue; } + if !has_basedpython_ui && transpiled.contains("basedpython_ui") { + continue; + } let py = tmp.path().join(format!( "{}_{i}.py", name.trim_end_matches(".md").replace('-', "_") diff --git a/crates/ty_ide/src/inlay_hints.rs b/crates/ty_ide/src/inlay_hints.rs index 7128a8659e..33ffc07e98 100644 --- a/crates/ty_ide/src/inlay_hints.rs +++ b/crates/ty_ide/src/inlay_hints.rs @@ -8,6 +8,7 @@ use crate::importer::{ImportAction, ImportRequest, Importer, MembersInScope}; use crate::{Db, HasNavigationTargets, NavigationTarget}; use ruff_db::parsed::parsed_module; use ruff_db::source::source_text; +use ruff_python_ast::helpers::is_compound_statement; use ruff_python_ast::name::Name; use ruff_python_ast::visitor::source_order::{self, SourceOrderVisitor, TraversalSignal}; use ruff_python_ast::{ @@ -23,10 +24,12 @@ use ty_python_semantic::reified::{ }; use ty_python_semantic::types::context_params::implicit_context_arguments; use ty_python_semantic::types::ide_support::{ - InlayHintCallArgumentDetails, hintable_parameter_type, implicit_enum_member_value, - inferred_override, inferred_raises, inferred_return_annotation, inferred_type_param_variance, - inherited_parameter_annotation, inherited_parameter_default, inlay_hint_call_argument_details, - is_reveal_type_function, is_union_special_form, numeric_promotion, + InferredInvalidations, InferredStateReads, InlayHintCallArgumentDetails, StateRead, WriteSite, + hintable_parameter_type, implicit_enum_member_value, inferred_derived_dependencies, + inferred_invalidations, inferred_override, inferred_raises, inferred_return_annotation, + inferred_state_reads, inferred_type_param_variance, inherited_parameter_annotation, + inherited_parameter_default, inlay_hint_call_argument_details, is_composable_function, + is_reveal_type_function, is_union_special_form, numeric_promotion, parameter_stability, trailing_lambda_implicit_parameters, type_parameter_names, }; use ty_python_semantic::types::{DisplaySettings, Type, TypeDetail}; @@ -301,6 +304,73 @@ impl InlayHint { } } + /// basedpython-ui: the observables a function reads while composing, shown + /// on its header after the return annotation and `raises` clause. + fn inferred_reads(position: TextSize, reads: &InferredStateReads) -> Self { + Self { + position, + kind: InlayHintKind::Reads, + label: InlayHintLabel { + parts: state_read_parts("reads ", &reads.reads, reads.opaque), + }, + padding_left: true, + padding_right: false, + text_edits: vec![], + } + } + + /// basedpython-ui: a composable parameter the runtime cannot compare, shown + /// where a modifier would be written before its name. A stable parameter + /// is the ordinary case and gets no hint. + fn unstable_parameter(position: TextSize) -> Self { + Self { + position, + kind: InlayHintKind::Stability, + label: InlayHintLabel { + parts: vec!["unstable".into()], + }, + padding_left: false, + padding_right: true, + text_edits: vec![], + } + } + + /// basedpython-ui: what a `derived(...)` / `remember(...)` computation + /// depends on, shown at the end of the call's line. + fn derived_dependencies(position: TextSize, reads: &InferredStateReads) -> Self { + Self { + position, + kind: InlayHintKind::DerivedDeps, + label: InlayHintLabel { + parts: state_read_parts("depends on ", &reads.reads, reads.opaque), + }, + padding_left: true, + padding_right: false, + text_edits: vec![], + } + } + + /// basedpython-ui: the composition scopes a state write made after + /// composing invalidates — the composables and `derived` computations + /// that read what is written — shown at the end of the statement, or of + /// the lambda, that writes. An empty set is spelled `nothing`: a write + /// nobody observes is worth seeing. + fn invalidations(position: TextSize, invalidations: &InferredInvalidations) -> Self { + let parts = if invalidations.scopes.is_empty() && !invalidations.opaque { + vec!["invalidates nothing".into()] + } else { + state_read_parts("invalidates ", &invalidations.scopes, invalidations.opaque) + }; + Self { + position, + kind: InlayHintKind::Invalidates, + label: InlayHintLabel { parts }, + padding_left: true, + padding_right: false, + text_edits: vec![], + } + } + /// The type arguments inferred for a generic call, shown between the callee /// and its argument list — where an explicit specialization would be written. fn call_type_arguments( @@ -575,6 +645,14 @@ pub enum InlayHintKind { TypeArgument, /// basedpython: a method that overrides a superclass member without saying so Override, + /// basedpython-ui: the observables a function reads while composing + Reads, + /// basedpython-ui: `unstable` on a composable parameter the runtime cannot compare + Stability, + /// basedpython-ui: what a `derived(...)` computation depends on + DerivedDeps, + /// basedpython-ui: the composition scopes a state write invalidates + Invalidates, /// The arms the typing spec's numeric promotion adds to `float` / `complex` NumericPromotion, /// The type a `reveal_type` call reveals @@ -675,6 +753,30 @@ impl From<&str> for InlayHintLabelPart { } } +/// The parts of a hint that lists observables, or the scopes that observe +/// one: `prefix`, then each name as a part navigable to its declaration, +/// comma-separated, and `…` when `opaque` — something the analysis could not +/// follow. +fn state_read_parts(prefix: &str, reads: &[StateRead], opaque: bool) -> Vec { + let mut parts: Vec = vec![prefix.into()]; + for (index, read) in reads.iter().enumerate() { + if index > 0 { + parts.push(", ".into()); + } + parts.push( + InlayHintLabelPart::new(read.name.as_str()) + .with_target(Some(NavigationTarget::from(read.declaration))), + ); + } + if opaque { + if !reads.is_empty() { + parts.push(", ".into()); + } + parts.push("…".into()); + } + parts +} + #[derive(Debug, Clone)] pub struct InlayHintTextEdit { pub range: TextRange, @@ -793,6 +895,47 @@ pub struct InlayHintSettings { /// ``` pub inferred_override: bool, + /// basedpython-ui: whether to show the observables a function reads while + /// composing, after its return annotation and `raises` clause. + /// + /// ```by + /// @composable + /// def Counter(step: int = 1)" reads count": + /// let count = state(0) + /// Text(f"{count.value}") + /// ``` + pub inferred_reads: bool, + + /// basedpython-ui: whether to show `unstable` before a composable parameter + /// whose type the runtime cannot compare, so the composable is never + /// skipped. A stable parameter gets no hint. + /// + /// ```by + /// @composable + /// def TodoList("unstable "items: list[str]): ... + /// ``` + pub parameter_stability: bool, + + /// basedpython-ui: whether to show what a `derived(...)` or `remember(...)` + /// computation depends on, at the end of the call's line. + /// + /// ```by + /// let full = derived(lambda: name.value + email.value)" depends on name, email" + /// ``` + pub derived_dependencies: bool, + + /// basedpython-ui: whether to show the composition scopes a state write + /// made after composing — in a handler, a lambda, a nested `def`, an + /// effect — invalidates, at the end of the write's line: the composables + /// and `derived` computations that read what is written. A write nobody + /// observes shows `nothing`. + /// + /// ```by + /// Button("+"): + /// count.value += step" invalidates Counter" + /// ``` + pub inferred_invalidations: bool, + /// Whether to show the extra arms the typing spec's numeric promotion adds /// to `float` and `complex` in a type expression. /// @@ -912,6 +1055,10 @@ impl InlayHintSettings { call_type_arguments: false, type_argument_names: false, inferred_override: false, + inferred_reads: false, + parameter_stability: false, + derived_dependencies: false, + inferred_invalidations: false, numeric_promotions: false, revealed_types: false, implicit_parameters: false, @@ -937,6 +1084,10 @@ impl InlayHintSettings { call_type_arguments, type_argument_names, inferred_override, + inferred_reads, + parameter_stability, + derived_dependencies, + inferred_invalidations, numeric_promotions, revealed_types, implicit_parameters, @@ -959,6 +1110,10 @@ impl InlayHintSettings { || call_type_arguments || type_argument_names || inferred_override + || inferred_reads + || parameter_stability + || derived_dependencies + || inferred_invalidations || numeric_promotions || revealed_types || implicit_parameters @@ -985,6 +1140,10 @@ impl Default for InlayHintSettings { call_type_arguments: true, type_argument_names: true, inferred_override: true, + inferred_reads: true, + parameter_stability: true, + derived_dependencies: true, + inferred_invalidations: true, numeric_promotions: true, revealed_types: true, implicit_parameters: true, @@ -1276,6 +1435,111 @@ impl<'a, 'db> InlayHintVisitor<'a, 'db> { )); } + /// basedpython-ui: hint the observables `function` reads while composing. + /// + /// The hint sits after everything the header spells — the return + /// annotation and the `raises` clause — and before the `:`, so it reads + /// as one more clause of the header. + fn add_inferred_reads(&mut self, function: &ast::StmtFunctionDef) { + if !self.settings.inferred_reads || !self.is_basedpython() { + return; + } + + let Some(reads) = function + .inferred_type(&self.model) + .and_then(|ty| inferred_state_reads(self.db, ty)) + else { + return; + }; + + let position = function + .raises + .as_deref() + .or(function.returns.as_deref()) + .map_or_else(|| function.parameters.end(), Ranged::end); + + self.hints.push(InlayHint::inferred_reads(position, &reads)); + } + + /// basedpython-ui: hint `unstable` before each parameter of the composable + /// `function` whose declared type the runtime cannot compare. + fn add_parameter_stability(&mut self, function: &ast::StmtFunctionDef) { + if !self.settings.parameter_stability || !self.is_basedpython() { + return; + } + if !function + .inferred_type(&self.model) + .is_some_and(|ty| is_composable_function(self.db, ty)) + { + return; + } + + let env = &self.model.program_environment(); + for parameter in function.parameters.iter_non_variadic_params() { + let parameter = ¶meter.parameter; + // an unannotated parameter's type says nothing the annotation did + // not leave out + if parameter.annotation.is_none() { + continue; + } + let Some(ty) = hintable_parameter_type(&self.model, parameter) else { + continue; + }; + if parameter_stability(self.db, env, ty) == Some(false) { + self.hints.push(InlayHint::unstable_parameter( + parameter.name.range().start(), + )); + } + } + } + + /// basedpython-ui: hint what a `derived(...)` / `remember(...)` call's + /// computation depends on, at the end of its line. + fn add_derived_dependencies(&mut self, call: &ast::ExprCall) { + if !self.settings.derived_dependencies || !self.is_basedpython() { + return; + } + + let Some(reads) = inferred_derived_dependencies(&self.model, call) else { + return; + }; + + self.hints.push(InlayHint::derived_dependencies( + self.source.line_end(call.range().end()), + &reads, + )); + } + + /// basedpython-ui: hint what the observable writes of `site` invalidate. + /// Only a write made after composing gets one: a write made while + /// composing is a diagnostic. + /// + /// The hint sits at the end of the site itself — after a simple + /// statement, after a lambda's body — so that two sites on one line + /// (`a.set(1); b.set(2)`, two lambdas in one call) get one each, in + /// place. A compound statement's header has no end of its own to sit + /// after, so a write in one (`if todos.pop():`) is hinted at the end of + /// the line it is spelled on. + fn add_invalidations(&mut self, site: WriteSite<'_>) { + if !self.settings.inferred_invalidations || !self.is_basedpython() { + return; + } + + let Some(invalidations) = inferred_invalidations(&self.model, site) else { + return; + }; + + let position = match site { + WriteSite::Statement(stmt) if is_compound_statement(stmt) => { + self.source.line_end(invalidations.anchor.end()) + } + WriteSite::Statement(stmt) => stmt.end(), + WriteSite::Lambda(lambda) => lambda.end(), + }; + self.hints + .push(InlayHint::invalidations(position, &invalidations)); + } + /// Hint the type arguments inferred for a generic call. fn add_call_type_arguments(&mut self, call: &ast::ExprCall, arguments: &[(Name, Type<'db>)]) { let env = &self.model.program_environment(); @@ -1677,6 +1941,10 @@ impl<'a> SourceOrderVisitor<'a> for InlayHintVisitor<'a, '_> { return; } + // basedpython-ui: a statement's own writes to state; the statements + // nested in it are visited below and hinted as sites of their own + self.add_invalidations(WriteSite::Statement(stmt)); + match stmt { Stmt::Assign(assign) => { // basedpython: a decorator may be written above a binding. A @@ -1761,8 +2029,10 @@ impl<'a> SourceOrderVisitor<'a> for InlayHintVisitor<'a, '_> { Stmt::FunctionDef(function) => { self.add_inferred_return(function); self.add_inferred_raises(function); + self.add_inferred_reads(function); self.add_inferred_reification(function); self.add_inferred_override(function); + self.add_parameter_stability(function); // a function nested in a method is not itself a class member let enclosing_class = self.enclosing_class.take(); @@ -1858,7 +2128,11 @@ impl<'a> SourceOrderVisitor<'a> for InlayHintVisitor<'a, '_> { self.add_numeric_promotion(expr); source_order::walk_expr(self, expr); } - Expr::Lambda(_) => { + Expr::Lambda(lambda) => { + // basedpython-ui: a lambda body runs later, so its writes to + // state are a site of their own — `on_click=lambda: count.set(0)` + self.add_invalidations(WriteSite::Lambda(lambda)); + // every parameter below a lambda belongs to a lambda let in_lambda = std::mem::replace(&mut self.in_lambda, true); source_order::walk_expr(self, expr); @@ -1906,6 +2180,7 @@ impl<'a> SourceOrderVisitor<'a> for InlayHintVisitor<'a, '_> { if reveals_type { self.add_revealed_type(call); } + self.add_derived_dependencies(call); // a string tag's argument is the abutting literal, not something // the reader passed by position, and a `cast` operator's are its @@ -2569,8 +2844,10 @@ Source with applied edits: /// `_` marks a space the client draws; a bare space is one the label carries. #[test] fn a_hint_leaves_the_space_beside_it_to_the_client() { - let mut test = basedpython_inlay_hint_test( - " + let mut test = basedpython_ui_inlay_hint_test( + r#" + from basedpython_ui import composable, derived, state, Text + class Base: def f(self) -> None: ... @@ -2583,7 +2860,13 @@ Source with applied edits: def make[T](): return T() - ", + + @composable + def Counter(items: list[int]): + let count = state(0) + let doubled = derived(lambda: count.value * 2) + Text(f"{doubled.value}") + "#, ); assert_snapshot!(test.padded_hints(&InlayHintSettings { @@ -2592,13 +2875,19 @@ Source with applied edits: inferred_variance: true, inferred_reification: true, inferred_override: true, + inferred_reads: true, + parameter_stability: true, + derived_dependencies: true, ..InlayHintSettings::none() - }), @r" + }), @" «override_» «_raises TypeError» «out_» «reified_» «_-> T@make» + «unstable_» + «_reads doubled» + «_depends on count» "); } @@ -10215,6 +10504,813 @@ Source with applied edits: })); } + /// The mock of the `basedpython_ui` framework the ui hints are tested + /// against: its observables, slot functions and the builders the examples + /// use, developed in place as a first-party package — which is how the + /// framework itself is developed. + const BASEDPYTHON_UI_INIT: &str = " +from .modifier export Modifier, Alignment, Arrangement, TextStyle, NONE, DEFAULT_TEXT_STYLE +from .runtime export ( + State, + StateList, + StateDict, + Derived, + Indexed, + state, + state_list, + state_dict, + derived, + remember, + composable, + Ambient, + ambient, + provide, + Job, + launched_effect, +) +from .widgets export ColumnScope, RowScope, Text, Button, TextField, Checkbox, Column, Row +from .app export run_app, compose_test, TestComposition +"; + + const BASEDPYTHON_UI_MODIFIER: &str = r##" +enum class Alignment: + case Start, Center, End + +enum class Arrangement: + case Start, Center, End + +frozen data class TextStyle: + color: str = "#202020" + size: float = 14.0 + +frozen data class Modifier: + ops: tuple[int, ...] = () + + def padding(self, all: int | float) -> Modifier: ... + +let NONE = Modifier() +let DEFAULT_TEXT_STYLE = TextStyle() +"##; + + const BASEDPYTHON_UI_RUNTIME: &str = " +from collections.abc import Iterable, Iterator + +class State[T]: + value: T + def __init__(self, initial: T) -> None: ... + def set(self, new: T) -> None: ... + def update(self, fn: (T) -> T) -> None: ... + +frozen data class Indexed[T]: + index: int + item: T + +class StateList[T]: + def __init__(self, initial: Iterable[T] = ()) -> None: ... + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[T]: ... + def __getitem__(self, index: int) -> T: ... + def __setitem__(self, index: int, value: T) -> None: ... + def append(self, value: T) -> None: ... + def insert(self, index: int, value: T) -> None: ... + def remove_at(self, index: int) -> None: ... + def remove(self, value: T) -> None: ... + def pop(self, index: int = -1) -> T: ... + def clear(self) -> None: ... + def index_where(self, predicate: (T) -> bool) -> int?: ... + def snapshot(self) -> tuple[T, ...]: ... + def each(self, key: (T) -> object, local content: (T) -> None) -> None: ... + def each_indexed(self, key: (T) -> object, local content: (Indexed[T]) -> None) -> None: ... + +class StateDict[K, V]: + def __len__(self) -> int: ... + def __contains__(self, key: K) -> bool: ... + def __getitem__(self, key: K) -> V: ... + def __setitem__(self, key: K, value: V) -> None: ... + def get(self, key: K) -> V | None: ... + def remove(self, key: K) -> None: ... + def keys(self) -> tuple[K, ...]: ... + def items(self) -> tuple[tuple[K, V], ...]: ... + +class Derived[T]: + value: T + +class Ambient[T]: + current: T + +class Job: ... + +class Runtime: + def set_root(self, root: () -> None) -> None: ... + +def state[T](initial: T) -> State[T]: ... +def state_list[T](initial: Iterable[T] = ()) -> StateList[T]: ... +def state_dict[K, V]() -> StateDict[K, V]: ... +def derived[T](compute: () -> T) -> Derived[T]: ... +def remember[T](compute: () -> T) -> T: ... +def composable[F](fn: F) -> F: ... +def ambient[T](default: T) -> Ambient[T]: ... +def provide[T](which: Ambient[T], value: T, once content: () -> None) -> None: ... +def launched_effect(key: object, block: (Job) -> None) -> None: ... +def builder[F](fn: F) -> F: ... +"; + + const BASEDPYTHON_UI_WIDGETS: &str = r#" +from .modifier import Modifier, Alignment, Arrangement, TextStyle, NONE, DEFAULT_TEXT_STYLE +from .runtime import builder + +class ColumnScope: + def weight(self, value: float) -> Modifier: ... + def align(self, alignment: Alignment) -> Modifier: ... + +class RowScope: + def weight(self, value: float) -> Modifier: ... + def align(self, alignment: Alignment) -> Modifier: ... + +@builder +def Text(text: str, modifier: Modifier = NONE, style: TextStyle = DEFAULT_TEXT_STYLE) -> None: ... +@builder +def Button(label: str, modifier: Modifier = NONE, enabled: bool = True, on_click: () -> None) -> None: ... +@builder +def TextField(value: str, modifier: Modifier = NONE, placeholder: str = "", on_change: (str) -> None) -> None: ... +@builder +def Checkbox(checked: bool, modifier: Modifier = NONE, on_change: (bool) -> None) -> None: ... +@builder +def Column(modifier: Modifier = NONE, arrangement: Arrangement = Arrangement.Start, once content: ColumnScope.() -> None) -> None: ... +@builder +def Row(modifier: Modifier = NONE, arrangement: Arrangement = Arrangement.Start, once content: RowScope.() -> None) -> None: ... +"#; + + const BASEDPYTHON_UI_APP: &str = r#" +class TestComposition: ... + +def run_app(title: str = "basedpython-ui", width: int = 800, height: int = 600, root: () -> None) -> None: ... +def compose_test(width: int = 400, height: int = 300, root: () -> None) -> TestComposition: ... +"#; + + /// Like [`basedpython_inlay_hint_test`], with the mock `basedpython_ui` + /// package installed beside the source. + fn basedpython_ui_inlay_hint_test(source: &str) -> InlayHintTest { + let mut test = basedpython_inlay_hint_test(source); + test.with_extra_file("basedpython_ui/__init__.by", BASEDPYTHON_UI_INIT); + test.with_extra_file("basedpython_ui/modifier.by", BASEDPYTHON_UI_MODIFIER); + test.with_extra_file("basedpython_ui/runtime.by", BASEDPYTHON_UI_RUNTIME); + test.with_extra_file("basedpython_ui/widgets.by", BASEDPYTHON_UI_WIDGETS); + test.with_extra_file("basedpython_ui/app.by", BASEDPYTHON_UI_APP); + test + } + + /// The four basedpython-ui hints, and nothing else. + fn basedpython_ui_settings() -> InlayHintSettings { + InlayHintSettings { + inferred_reads: true, + parameter_stability: true, + derived_dependencies: true, + inferred_invalidations: true, + ..InlayHintSettings::none() + } + } + + /// The observables a function reads while composing: `.value` on a state + /// or a derived, `.current` on an ambient, iteration / `len` / a subscript + /// / `in` / `each` on a collection, and a `context` parameter — in the + /// body and its content blocks, never in a handler, a lambda or a nested + /// `def`. A callee's reads come through its parameters and globals, its + /// own cells stay its own, and a callee that cannot be followed is `…` — + /// which on its own is worth saying of a composable, and of nothing else. + #[test] + fn basedpython_inferred_reads() { + let mut test = basedpython_ui_inlay_hint_test( + r#" + from basedpython_ui import State, StateDict, StateList, ambient, composable, derived, state, Button, Column, Text + + let THEME = ambient("light") + + class Model: + count: State[int] + + def total(self) -> int: + return self.count.value + + def label(cell: State[int]) -> str: + return f"{cell.value}" + + def loud(cell: State[int]) -> str: + let local = state(0) + return label(cell) + str(local.value) + + @composable + def Counter(step: int = 1): + let count = state(0) + Column: + Text(loud(count)) + Button("+"): + count.value += step + + @composable + def Dashboard(model: Model, items: StateList[str], table: StateDict[str, int], context depth: int): + let total = derived(lambda: model.total()) + Text(f"{total.value} {model.total()} {len(items)} {THEME.current} {depth}") + for item in items: + Text(item) + if "a" in table: + Text(str(table["a"])) + items.each(key=lambda item: item): + Text(it) + + def later(): + Text(str(items[0])) + + Button("x", on_click=lambda: model.count.set(0)) + + def opaque(cell: State[int], thing: dynamic) -> int: + thing() + return cell.value + + def entry(thing: dynamic): + thing() + + @composable + def Blind(thing: dynamic): + thing() + + def declared(cell: State[int]) -> int raises ValueError: + return cell.value + + def nothing(): ... + "#, + ); + + assert_snapshot!(test.inlay_hints_with_settings(&InlayHintSettings { + inferred_reads: true, + ..InlayHintSettings::none() + })); + } + + /// `unstable` goes before a composable parameter whose declared type the + /// runtime cannot compare; a stable one, an unannotated one and a plain + /// function's parameters get nothing. + #[test] + fn basedpython_parameter_stability() { + let mut test = basedpython_ui_inlay_hint_test( + " + from basedpython_ui import State, StateList, composable + + frozen data class Todo: + title: str + + data class Draft: + title: str + + @composable + def TodoList( + items: list[str], + view: list[out str], + todos: StateList[Todo], + draft: Draft, + todo: Todo, + count: State[int], + on_click: () -> None, + untyped, + table: dict[str, int] = {}, + ): ... + + def helper(items: list[str]): ... + ", + ); + + assert_snapshot!(test.inlay_hints_with_settings(&InlayHintSettings { + parameter_stability: true, + ..InlayHintSettings::none() + })); + } + + /// What a `derived` or `remember` computation depends on is shown at the + /// end of its line: the observables its lambda reads, its callees + /// followed. A computation that reads nothing gets no hint. + #[test] + fn basedpython_derived_dependencies() { + let mut test = basedpython_ui_inlay_hint_test( + r#" + from basedpython_ui import State, composable, derived, remember, state, state_list + + def validate(value: str) -> str?: + return None if value else "required" + + def total_of(cell: State[int]) -> int: + return cell.value + + @composable + def Form(): + let name = state("") + let email = state("") + let count = state(0) + let items = state_list([1]) + let name_error = derived(lambda: validate(name.value)) + let full = derived(lambda: name.value + email.value) + let total = derived(lambda: total_of(count) + sum(1 for item in items if item)) + let cached = remember(lambda: count.value * 2) + let constant = derived(lambda: 1) + "#, + ); + + assert_snapshot!(test.inlay_hints_with_settings(&InlayHintSettings { + derived_dependencies: true, + ..InlayHintSettings::none() + })); + } + + /// What a state write made after composing invalidates: the composables + /// whose own composition reads the place — through the body, its content + /// blocks and the plain functions it calls, and through a composable + /// called with a content block, which the runtime runs inline in its + /// parent — the `derived` values whose lambda reads it and then their + /// readers, a child handed the slot (directly, through a plain helper's + /// parameter, from another module) when it reads its parameter, the + /// `root` of an entry point. A slot declared in a content block is its + /// composable's; a name bound to another place is followed to it, in the + /// body and in a handler. Callers, inline parents and readers of a + /// module-level slot in other files cannot be followed, nor can a + /// `dynamic` callee, an unpacked argument, or a written name that is not + /// a slot — a loop target, a value bound outside composition — so those + /// sets end in `…`. A write nobody observes says `nothing`; a write made + /// while composing, and a write to something that is not an observable, + /// get no hint. Each site is hinted at its own end, so two statements on + /// one line get one each. + #[test] + fn basedpython_invalidations() { + let mut test = basedpython_ui_inlay_hint_test( + r#" + from basedpython_ui import ( + Derived, State, StateDict, ambient, composable, compose_test, derived, launched_effect, remember, + state, state_dict, state_list, Button, Column, Text, + ) + from basedpython_ui.runtime import Runtime + from remote import Remote + + let THEME = ambient("light") + let CLICKS = State(0) + + + class Model: + count: State[int] + + def total(self) -> int: + return self.count.value + + def reset(self): + self.count.set(0) + + + @composable + def Child(count: State[int]): + Text(str(count.value)) + + + @composable + def Forwarding(count: State[int]): + Child(count) + + + @composable + def Display(total: Derived[int]): + Text(str(total.value)) + + + @composable + def Counter(step: int = 1): + let count = state(0) + let seed = state(1) + let unread = state(0) + let total = derived(lambda: count.value * 2) + let cached = remember(lambda: seed.value) + let todos = state_list([1]) + let table: StateDict[str, int] = state_dict() + let plain = [1] + Column: + Text(f"{count.value} {total.value} {cached} {len(todos)} {table.get('a')}") + Forwarding(count) + Display(total) + Button("+"): + count.value += step + Button("reset", on_click=lambda: count.set(0)) + Button("todo"): + todos.append(2) + table["a"] = 1 + Button("both"): + count.value += step; todos.append(3) + Button("seed"): + seed.value = 2 + Button("unread"): + unread.set(1) + Button("plain"): + plain.append(1) + launched_effect(None): + count.value = 5 + + def later(): + count.value = 0 + + + @composable + def Outer(): + let shared = state(0) + + @composable + def Inner(): + Text(str(shared.value)) + + Inner() + Button("bump"): + shared.value += 1 + + + @composable + def OuterInline(): + let shared = state(0) + + @composable + def Inner(once content: () -> None): + Text(str(shared.value)) + content() + + Inner(): + Text("x") + Button("bump"): + shared.value += 1 + + + @composable + def Themed(): + Text(THEME.current) + Text(str(CLICKS.value)) + Button("click", on_click=lambda: CLICKS.set(1)) + + + @composable + def Other(): + Text(str(CLICKS.value)) + + + def reset_clicks(): + CLICKS.set(0) + + + @composable + def Editor(count: State[int]): + Text(str(count.value)) + Button("+"): + count.value += 1 + + + @composable + def Host(): + let count = state(0) + Text(str(count.value)) + Editor(count) + Remote(count) + Button("+"): + count.value += 1 + + + @composable + def Blind(thing: dynamic): + let cell = state(0) + thing(cell) + Button("x"): + cell.set(1) + + + @composable + def Spread(): + let count = state(0) + let cells = (count,) + Child(*cells) + Button("x"): + count.set(1) + + + @composable + def Broken(): + let count = state(0) + count.value = 1 + Text(str(count.value)) + + + @composable + def Dashboard(model: Model): + Text(str(model.total())) + Button("reset", on_click=lambda: model.count.set(0)) + Button("local"): + let cell = model.count + cell.set(2) + + + @composable + def Card(count: State[int], once content: () -> None): + let expanded = state(False) + Text(f"{count.value} {expanded.value}") + content() + Button("expand"): + expanded.set(True) + Button("zero"): + count.set(0) + + + @composable + def Inline(): + let count = state(0) + Card(count): + Text("inner") + Child(count) + Button("+"): + count.value += 1 + + + @composable + private def Mid(count: State[int], once content: () -> None): + let seen = state(False) + Text(str(seen.value)) + Card(count): + content() + Button("seen"): + seen.set(True) + + + @composable + def Top(): + let count = state(0) + let flag = state(False) + Text(str(flag.value)) + Mid(count): + Text("deep") + Button("+"): + count.value += 1 + Button("flag"): + flag.set(True) + + + @composable + def InBlock(): + Column: + let inner = state(0) + let twice = derived(lambda: inner.value * 2) + Text(f"{inner.value} {twice.value}") + Child(inner) + Button("+"): + inner.value += 1 + + + @composable + def WriterAlias(): + let count = state(0) + Text(str(count.value)) + Button("alias"): + let alias = count + alias.set(5) + Button("loop"): + for c in [count]: + c.set(6) + Button("comp"): + _ = [c.set(7) for c in [count]] + + + @composable + def ReaderAlias(): + let count = state(0) + let alias = count + Text(str(alias.value)) + Button("+"): + count.value += 1 + + + private def render(cell: State[int]): + Child(cell) + + + @composable + def ViaHelper(): + let count = state(0) + render(count) + + def captured(): + Editor(count) + + captured() + Button("+"): + count.value += 1 + + + def install(rt: Runtime): + def root(): + let c = state(0) + Text(str(c.value)) + Button("+"): + c.value += 1 + rt.set_root(root) + + + def install_lambda(rt: Runtime): + let cell = State(0) + rt.set_root(lambda: Child(cell)) + + def bump(): + cell.set(1) + + + def test_root(): + let t = compose_test: + let count = state(0) + Text(str(count.value)) + Button("+"): + count.value += 1 + "#, + ); + test.with_extra_file( + "remote.by", + r#" +from basedpython_ui import State, composable, Text + + +@composable +def Remote(cell: State[int]): + Text(f"remote {cell.value}") +"#, + ); + + assert_snapshot!(test.inlay_hints_with_settings(&InlayHintSettings { + inferred_invalidations: true, + ..InlayHintSettings::none() + })); + } + + /// `examples/counter.by` of basedpython-ui, verbatim. + #[test] + fn basedpython_ui_counter_example() { + let mut test = basedpython_ui_inlay_hint_test( + r#" + from basedpython_ui import composable, state, Column, Row, Text, Button, Modifier, Alignment, run_app + + + @composable + def Counter(step: int = 1): + let count = state(0) # State[int], remembered for this scope's lifetime + Column(Modifier().padding(16)): + Text(f"count = {count.value}") # a tracked read: this scope now depends on `count` + Row: + Button("-", enabled=count.value > 0): + count.value -= step # the block binds `on_click`; a write invalidates readers + Button("+"): + count.value += step + Button("reset", enabled=count.value != 0): + count.value = 0 + if count.value > 9: + Text("that is a lot", modifier=align(Alignment.Center)) # `align` comes from the ColumnScope receiver + + + @composable + def App(): + Column: + Counter() + Counter(step=5) + + + def main(): + run_app("counter"): + App() + "#, + ); + + assert_snapshot!(test.inlay_hints_with_settings(&basedpython_ui_settings())); + } + + /// `examples/todo.by` of basedpython-ui, verbatim. + #[test] + fn basedpython_ui_todo_example() { + let mut test = basedpython_ui_inlay_hint_test( + r#" + from basedpython_ui import ( + composable, state, state_list, derived, Column, Row, Text, Button, Checkbox, TextField, Modifier, run_app, + ) + + + frozen data class Todo: # immutable: the only way to change a todo is to replace it in the list + id: int + title: str + done: bool = False + + + @composable + def TodoRow(todo: Todo, on_delete: () -> None, on_toggle: (bool) -> None): + Row(Modifier().padding(4)): + Checkbox(todo.done, on_change=on_toggle) + Text(todo.title, modifier=weight(1.0)) + Button("x", on_click=on_delete) + + + @composable + def TodoApp(): + let todos = state_list([Todo(1, "write the runtime"), Todo(2, "ship it")]) # StateList[Todo]: observable + let draft = state("") + let next_id = state(3) + let remaining = derived(lambda: sum(1 for t in todos if not t.done)) # ⟨depends on todos⟩ + + Column(Modifier().padding(12)): + Text(f"{remaining.value} of {len(todos)} remaining") + Row: + TextField(draft.value, placeholder="what needs doing?", modifier=weight(1.0)): + draft.value = it # `it: str`, the new text + Button("add", enabled=draft.value != ""): + todos.append(Todo(next_id.value, draft.value)) + next_id.value += 1 + draft.value = "" + todos.each_indexed(key=lambda todo: todo.id): # keyed children; `it` is this row + let row = it # the inner block below has its own `it` + TodoRow(row.item, on_delete=lambda: todos.remove_at(row.index)): + todos[row.index] = Todo(row.item.id, row.item.title, it) # `it: bool` from `on_toggle` + if len(todos) == 0: + Text("nothing to do") + + + def main(): + run_app("todos"): + TodoApp() + "#, + ); + + assert_snapshot!(test.inlay_hints_with_settings(&basedpython_ui_settings())); + } + + /// `examples/form.by` of basedpython-ui, verbatim. + #[test] + fn basedpython_ui_form_example() { + let mut test = basedpython_ui_inlay_hint_test( + r##" + from basedpython_ui import composable, state, derived, Column, Row, Text, Button, TextField, Modifier, TextStyle, run_app + + + let ERROR_STYLE = TextStyle(color="#b00020") + + + frozen data class Signup: + name: str + email: str + + + def validate_name(value: str) -> str?: + return "name is required" if value.strip() == "" else None + + + def validate_email(value: str) -> str?: + if "@" not in value or value.startswith("@"): + return "enter a valid e-mail address" + return None + + + @composable + def Field(label: str, value: str, error: str?, on_change: (str) -> None): + Column(Modifier().padding(4)): + Text(label) + TextField(value, on_change=on_change) + if error is not None: + Text(error, style=ERROR_STYLE) + + + @composable + def SignupForm(on_submit: (Signup) -> None): + let name = state("") + let email = state("") + let submitted = state(False) + # derived values are recomputed only when a dependency changes, and never observed half-updated + let name_error = derived(lambda: validate_name(name.value)) + let email_error = derived(lambda: validate_email(email.value)) + let can_submit = derived(lambda: name_error.value is None and email_error.value is None) + + Column(Modifier().padding(16)): + Field("name", name.value, name_error.value if submitted.value else None): + name.value = it + Field("e-mail", email.value, email_error.value if submitted.value else None): + email.value = it + Row: + Button("sign up", enabled=can_submit.value or not submitted.value): + submitted.value = True + if can_submit.value: + on_submit(Signup(name.value, email.value)) + Button("clear"): + name.value = "" + email.value = "" + submitted.value = False + + + def main(): + run_app("sign up"): + SignupForm(on_submit=lambda s: print("welcome", s.name, s.email)) + "##, + ); + + assert_snapshot!(test.inlay_hints_with_settings(&basedpython_ui_settings())); + } + #[test] fn basedpython_implicit_parameters() { let mut test = basedpython_inlay_hint_test( diff --git a/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_derived_dependencies.snap b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_derived_dependencies.snap new file mode 100644 index 0000000000..68a7ed6851 --- /dev/null +++ b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_derived_dependencies.snap @@ -0,0 +1,91 @@ +--- +source: crates/ty_ide/src/inlay_hints.rs +expression: "test.inlay_hints_with_settings(&InlayHintSettings\n{ derived_dependencies: true, ..InlayHintSettings::none() })" +--- + +from basedpython_ui import State, composable, derived, remember, state, state_list + +def validate(value: str) -> str?: + return None if value else "required" + +def total_of(cell: State[int]) -> int: + return cell.value + +@composable +def Form(): + let name = state("") + let email = state("") + let count = state(0) + let items = state_list([1]) + let name_error = derived(lambda: validate(name.value))[ depends on name] + let full = derived(lambda: name.value + email.value)[ depends on name, email] + let total = derived(lambda: total_of(count) + sum(1 for item in items if item))[ depends on count, items] + let cached = remember(lambda: count.value * 2)[ depends on count] + let constant = derived(lambda: 1) + +--------------------------------------------- +info[inlay-hint-location]: Inlay Hint Target + --> main.by:12:9 + | +12 | let name = state("") + | ^^^^ +info: Source + --> main2.py:16:72 + | +16 | let name_error = derived(lambda: validate(name.value))[ depends on name] + | ^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:12:9 + | +12 | let name = state("") + | ^^^^ +info: Source + --> main2.py:17:70 + | +17 | let full = derived(lambda: name.value + email.value)[ depends on name, email] + | ^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:13:9 + | +13 | let email = state("") + | ^^^^^ +info: Source + --> main2.py:17:76 + | +17 | let full = derived(lambda: name.value + email.value)[ depends on name, email] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:14:9 + | +14 | let count = state(0) + | ^^^^^ +info: Source + --> main2.py:18:97 + | +18 | let total = derived(lambda: total_of(count) + sum(1 for item in items if item))[ depends on count, items] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:15:9 + | +15 | let items = state_list([1]) + | ^^^^^ +info: Source + --> main2.py:18:104 + | +18 | let total = derived(lambda: total_of(count) + sum(1 for item in items if item))[ depends on count, items] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:14:9 + | +14 | let count = state(0) + | ^^^^^ +info: Source + --> main2.py:19:64 + | +19 | let cached = remember(lambda: count.value * 2)[ depends on count] + | ^^^^^ diff --git a/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_inferred_reads.snap b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_inferred_reads.snap new file mode 100644 index 0000000000..012f765721 --- /dev/null +++ b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_inferred_reads.snap @@ -0,0 +1,216 @@ +--- +source: crates/ty_ide/src/inlay_hints.rs +expression: "test.inlay_hints_with_settings(&InlayHintSettings\n{ inferred_reads: true, ..InlayHintSettings::none() })" +--- + +from basedpython_ui import State, StateDict, StateList, ambient, composable, derived, state, Button, Column, Text + +let THEME = ambient("light") + +class Model: + count: State[int] + + def total(self) -> int[ reads self.count]: + return self.count.value + +def label(cell: State[int]) -> str[ reads cell]: + return f"{cell.value}" + +def loud(cell: State[int]) -> str[ reads cell, local]: + let local = state(0) + return label(cell) + str(local.value) + +@composable +def Counter(step: int = 1)[ reads count]: + let count = state(0) + Column: + Text(loud(count)) + Button("+"): + count.value += step + +@composable +def Dashboard(model: Model, items: StateList[str], table: StateDict[str, int], context depth: int)[ reads THEME, model.count, items, table, depth, total]: + let total = derived(lambda: model.total()) + Text(f"{total.value} {model.total()} {len(items)} {THEME.current} {depth}") + for item in items: + Text(item) + if "a" in table: + Text(str(table["a"])) + items.each(key=lambda item: item): + Text(it) + + def later()[ reads items]: + Text(str(items[0])) + + Button("x", on_click=lambda: model.count.set(0)) + +def opaque(cell: State[int], thing: dynamic) -> int[ reads cell, …]: + thing() + return cell.value + +def entry(thing: dynamic): + thing() + +@composable +def Blind(thing: dynamic)[ reads …]: + thing() + +def declared(cell: State[int]) -> int raises ValueError[ reads cell]: + return cell.value + +def nothing(): ... + +--------------------------------------------- +info[inlay-hint-location]: Inlay Hint Target + --> main.by:9:15 + | +9 | def total(self) -> int: + | ^^^^ +info: Source + --> main2.py:9:35 + | +9 | def total(self) -> int[ reads self.count]: + | ^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:12:11 + | +12 | def label(cell: State[int]) -> str: + | ^^^^ +info: Source + --> main2.py:12:43 + | +12 | def label(cell: State[int]) -> str[ reads cell]: + | ^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:15:10 + | +15 | def loud(cell: State[int]) -> str: + | ^^^^ +info: Source + --> main2.py:15:42 + | +15 | def loud(cell: State[int]) -> str[ reads cell, local]: + | ^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:16:9 + | +16 | let local = state(0) + | ^^^^^ +info: Source + --> main2.py:15:48 + | +15 | def loud(cell: State[int]) -> str[ reads cell, local]: + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:21:9 + | +21 | let count = state(0) + | ^^^^^ +info: Source + --> main2.py:20:35 + | +20 | def Counter(step: int = 1)[ reads count]: + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:4:5 + | +4 | let THEME = ambient("light") + | ^^^^^ +info: Source + --> main2.py:28:107 + | +28 | def Dashboard(model: Model, items: StateList[str], table: StateDict[str, int], context depth: int)[ reads THEME, model.count, items, t… + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:28:15 + | +28 | def Dashboard(model: Model, items: StateList[str], table: StateDict[str, int], context depth: int): + | ^^^^^ +info: Source + --> main2.py:28:114 + | +28 | def Dashboard(model: Model, items: StateList[str], table: StateDict[str, int], context depth: int)[ reads THEME, model.count, items, t… + | ^^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:28:29 + | +28 | def Dashboard(model: Model, items: StateList[str], table: StateDict[str, int], context depth: int): + | ^^^^^ +info: Source + --> main2.py:28:127 + | +28 | …eDict[str, int], context depth: int)[ reads THEME, model.count, items, table, depth, total]: + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:28:52 + | +28 | def Dashboard(model: Model, items: StateList[str], table: StateDict[str, int], context depth: int): + | ^^^^^ +info: Source + --> main2.py:28:134 + | +28 | …tr, int], context depth: int)[ reads THEME, model.count, items, table, depth, total]: + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:28:88 + | +28 | def Dashboard(model: Model, items: StateList[str], table: StateDict[str, int], context depth: int): + | ^^^^^ +info: Source + --> main2.py:28:141 + | +28 | …], context depth: int)[ reads THEME, model.count, items, table, depth, total]: + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:29:9 + | +29 | let total = derived(lambda: model.total()) + | ^^^^^ +info: Source + --> main2.py:28:148 + | +28 | …ext depth: int)[ reads THEME, model.count, items, table, depth, total]: + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:28:29 + | +28 | def Dashboard(model: Model, items: StateList[str], table: StateDict[str, int], context depth: int): + | ^^^^^ +info: Source + --> main2.py:38:24 + | +38 | def later()[ reads items]: + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:43:12 + | +43 | def opaque(cell: State[int], thing: dynamic) -> int: + | ^^^^ +info: Source + --> main2.py:43:60 + | +43 | def opaque(cell: State[int], thing: dynamic) -> int[ reads cell, …]: + | ^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:54:14 + | +54 | def declared(cell: State[int]) -> int raises ValueError: + | ^^^^ +info: Source + --> main2.py:54:64 + | +54 | def declared(cell: State[int]) -> int raises ValueError[ reads cell]: + | ^^^^ diff --git a/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_invalidations.snap b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_invalidations.snap new file mode 100644 index 0000000000..099103ecc2 --- /dev/null +++ b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_invalidations.snap @@ -0,0 +1,1070 @@ +--- +source: crates/ty_ide/src/inlay_hints.rs +expression: "test.inlay_hints_with_settings(&InlayHintSettings\n{ inferred_invalidations: true, ..InlayHintSettings::none() })" +--- + +from basedpython_ui import ( + Derived, State, StateDict, ambient, composable, compose_test, derived, launched_effect, remember, + state, state_dict, state_list, Button, Column, Text, +) +from basedpython_ui.runtime import Runtime +from remote import Remote + +let THEME = ambient("light") +let CLICKS = State(0) + + +class Model: + count: State[int] + + def total(self) -> int: + return self.count.value + + def reset(self): + self.count.set(0)[ invalidates …] + + +@composable +def Child(count: State[int]): + Text(str(count.value)) + + +@composable +def Forwarding(count: State[int]): + Child(count) + + +@composable +def Display(total: Derived[int]): + Text(str(total.value)) + + +@composable +def Counter(step: int = 1): + let count = state(0) + let seed = state(1) + let unread = state(0) + let total = derived(lambda: count.value * 2) + let cached = remember(lambda: seed.value) + let todos = state_list([1]) + let table: StateDict[str, int] = state_dict() + let plain = [1] + Column: + Text(f"{count.value} {total.value} {cached} {len(todos)} {table.get('a')}") + Forwarding(count) + Display(total) + Button("+"): + count.value += step[ invalidates Child, Display, Counter, total] + Button("reset", on_click=lambda: count.set(0)[ invalidates Child, Display, Counter, total]) + Button("todo"): + todos.append(2)[ invalidates Counter] + table["a"] = 1[ invalidates Counter] + Button("both"): + count.value += step[ invalidates Child, Display, Counter, total]; todos.append(3)[ invalidates Counter] + Button("seed"): + seed.value = 2[ invalidates Counter] + Button("unread"): + unread.set(1)[ invalidates nothing] + Button("plain"): + plain.append(1) + launched_effect(None): + count.value = 5[ invalidates Child, Display, Counter, total] + + def later(): + count.value = 0[ invalidates Child, Display, Counter, total] + + +@composable +def Outer(): + let shared = state(0) + + @composable + def Inner(): + Text(str(shared.value)) + + Inner() + Button("bump"): + shared.value += 1[ invalidates Inner] + + +@composable +def OuterInline(): + let shared = state(0) + + @composable + def Inner(once content: () -> None): + Text(str(shared.value)) + content() + + Inner(): + Text("x") + Button("bump"): + shared.value += 1[ invalidates OuterInline, Inner] + + +@composable +def Themed(): + Text(THEME.current) + Text(str(CLICKS.value)) + Button("click", on_click=lambda: CLICKS.set(1)[ invalidates Themed, Other, …]) + + +@composable +def Other(): + Text(str(CLICKS.value)) + + +def reset_clicks(): + CLICKS.set(0)[ invalidates Themed, Other, …] + + +@composable +def Editor(count: State[int]): + Text(str(count.value)) + Button("+"): + count.value += 1[ invalidates Child, Editor, Host, Remote, …] + + +@composable +def Host(): + let count = state(0) + Text(str(count.value)) + Editor(count) + Remote(count) + Button("+"): + count.value += 1[ invalidates Editor, Host, Remote] + + +@composable +def Blind(thing: dynamic): + let cell = state(0) + thing(cell) + Button("x"): + cell.set(1)[ invalidates …] + + +@composable +def Spread(): + let count = state(0) + let cells = (count,) + Child(*cells) + Button("x"): + count.set(1)[ invalidates …] + + +@composable +def Broken(): + let count = state(0) + count.value = 1 + Text(str(count.value)) + + +@composable +def Dashboard(model: Model): + Text(str(model.total())) + Button("reset", on_click=lambda: model.count.set(0)[ invalidates Dashboard, …]) + Button("local"): + let cell = model.count + cell.set(2)[ invalidates Dashboard, …] + + +@composable +def Card(count: State[int], once content: () -> None): + let expanded = state(False) + Text(f"{count.value} {expanded.value}") + content() + Button("expand"): + expanded.set(True)[ invalidates Card, Inline, Mid, Top, …] + Button("zero"): + count.set(0)[ invalidates Child, Card, Inline, Mid, Top, …] + + +@composable +def Inline(): + let count = state(0) + Card(count): + Text("inner") + Child(count) + Button("+"): + count.value += 1[ invalidates Child, Card, Inline] + + +@composable +private def Mid(count: State[int], once content: () -> None): + let seen = state(False) + Text(str(seen.value)) + Card(count): + content() + Button("seen"): + seen.set(True)[ invalidates Card, Mid, Top] + + +@composable +def Top(): + let count = state(0) + let flag = state(False) + Text(str(flag.value)) + Mid(count): + Text("deep") + Button("+"): + count.value += 1[ invalidates Card, Mid, Top] + Button("flag"): + flag.set(True)[ invalidates Card, Mid, Top] + + +@composable +def InBlock(): + Column: + let inner = state(0) + let twice = derived(lambda: inner.value * 2) + Text(f"{inner.value} {twice.value}") + Child(inner) + Button("+"): + inner.value += 1[ invalidates Child, InBlock, twice] + + +@composable +def WriterAlias(): + let count = state(0) + Text(str(count.value)) + Button("alias"): + let alias = count + alias.set(5)[ invalidates WriterAlias] + Button("loop"): + for c in [count]: + c.set(6)[ invalidates …] + Button("comp"): + _ = [c.set(7) for c in [count]][ invalidates …] + + +@composable +def ReaderAlias(): + let count = state(0) + let alias = count + Text(str(alias.value)) + Button("+"): + count.value += 1[ invalidates ReaderAlias] + + +private def render(cell: State[int]): + Child(cell) + + +@composable +def ViaHelper(): + let count = state(0) + render(count) + + def captured(): + Editor(count) + + captured() + Button("+"): + count.value += 1[ invalidates Child, Editor] + + +def install(rt: Runtime): + def root(): + let c = state(0) + Text(str(c.value)) + Button("+"): + c.value += 1[ invalidates root] + rt.set_root(root) + + +def install_lambda(rt: Runtime): + let cell = State(0) + rt.set_root(lambda: Child(cell)) + + def bump(): + cell.set(1)[ invalidates Child, …] + + +def test_root(): + let t = compose_test: + let count = state(0) + Text(str(count.value)) + Button("+"): + count.value += 1[ invalidates root] + +--------------------------------------------- +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Child(count: State[int]): + | ^^^^^ +info: Source + --> main2.py:53:46 + | +53 | count.value += step[ invalidates Child, Display, Counter, total] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:34:5 + | +34 | def Display(total: Derived[int]): + | ^^^^^^^ +info: Source + --> main2.py:53:53 + | +53 | count.value += step[ invalidates Child, Display, Counter, total] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:39:5 + | +39 | def Counter(step: int = 1): + | ^^^^^^^ +info: Source + --> main2.py:53:62 + | +53 | count.value += step[ invalidates Child, Display, Counter, total] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:43:9 + | +43 | let total = derived(lambda: count.value * 2) + | ^^^^^ +info: Source + --> main2.py:53:71 + | +53 | count.value += step[ invalidates Child, Display, Counter, total] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Child(count: State[int]): + | ^^^^^ +info: Source + --> main2.py:54:68 + | +54 | Button("reset", on_click=lambda: count.set(0)[ invalidates Child, Display, Counter, total]) + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:34:5 + | +34 | def Display(total: Derived[int]): + | ^^^^^^^ +info: Source + --> main2.py:54:75 + | +54 | Button("reset", on_click=lambda: count.set(0)[ invalidates Child, Display, Counter, total]) + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:39:5 + | +39 | def Counter(step: int = 1): + | ^^^^^^^ +info: Source + --> main2.py:54:84 + | +54 | Button("reset", on_click=lambda: count.set(0)[ invalidates Child, Display, Counter, total]) + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:43:9 + | +43 | let total = derived(lambda: count.value * 2) + | ^^^^^ +info: Source + --> main2.py:54:93 + | +54 | Button("reset", on_click=lambda: count.set(0)[ invalidates Child, Display, Counter, total]) + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:39:5 + | +39 | def Counter(step: int = 1): + | ^^^^^^^ +info: Source + --> main2.py:56:42 + | +56 | todos.append(2)[ invalidates Counter] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:39:5 + | +39 | def Counter(step: int = 1): + | ^^^^^^^ +info: Source + --> main2.py:57:41 + | +57 | table["a"] = 1[ invalidates Counter] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Child(count: State[int]): + | ^^^^^ +info: Source + --> main2.py:59:46 + | +59 | count.value += step[ invalidates Child, Display, Counter, total]; todos.append(3)[ invalidates Counter] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:34:5 + | +34 | def Display(total: Derived[int]): + | ^^^^^^^ +info: Source + --> main2.py:59:53 + | +59 | count.value += step[ invalidates Child, Display, Counter, total]; todos.append(3)[ invalidates Counter] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:39:5 + | +39 | def Counter(step: int = 1): + | ^^^^^^^ +info: Source + --> main2.py:59:62 + | +59 | count.value += step[ invalidates Child, Display, Counter, total]; todos.append(3)[ invalidates Counter] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:43:9 + | +43 | let total = derived(lambda: count.value * 2) + | ^^^^^ +info: Source + --> main2.py:59:71 + | +59 | count.value += step[ invalidates Child, Display, Counter, total]; todos.append(3)[ invalidates Counter] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:39:5 + | +39 | def Counter(step: int = 1): + | ^^^^^^^ +info: Source + --> main2.py:59:108 + | +59 | count.value += step[ invalidates Child, Display, Counter, total]; todos.append(3)[ invalidates Counter] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:39:5 + | +39 | def Counter(step: int = 1): + | ^^^^^^^ +info: Source + --> main2.py:61:41 + | +61 | seed.value = 2[ invalidates Counter] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Child(count: State[int]): + | ^^^^^ +info: Source + --> main2.py:67:38 + | +67 | count.value = 5[ invalidates Child, Display, Counter, total] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:34:5 + | +34 | def Display(total: Derived[int]): + | ^^^^^^^ +info: Source + --> main2.py:67:45 + | +67 | count.value = 5[ invalidates Child, Display, Counter, total] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:39:5 + | +39 | def Counter(step: int = 1): + | ^^^^^^^ +info: Source + --> main2.py:67:54 + | +67 | count.value = 5[ invalidates Child, Display, Counter, total] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:43:9 + | +43 | let total = derived(lambda: count.value * 2) + | ^^^^^ +info: Source + --> main2.py:67:63 + | +67 | count.value = 5[ invalidates Child, Display, Counter, total] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Child(count: State[int]): + | ^^^^^ +info: Source + --> main2.py:70:38 + | +70 | count.value = 0[ invalidates Child, Display, Counter, total] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:34:5 + | +34 | def Display(total: Derived[int]): + | ^^^^^^^ +info: Source + --> main2.py:70:45 + | +70 | count.value = 0[ invalidates Child, Display, Counter, total] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:39:5 + | +39 | def Counter(step: int = 1): + | ^^^^^^^ +info: Source + --> main2.py:70:54 + | +70 | count.value = 0[ invalidates Child, Display, Counter, total] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:43:9 + | +43 | let total = derived(lambda: count.value * 2) + | ^^^^^ +info: Source + --> main2.py:70:63 + | +70 | count.value = 0[ invalidates Child, Display, Counter, total] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:78:9 + | +78 | def Inner(): + | ^^^^^ +info: Source + --> main2.py:83:40 + | +83 | shared.value += 1[ invalidates Inner] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:87:5 + | +87 | def OuterInline(): + | ^^^^^^^^^^^ +info: Source + --> main2.py:98:40 + | +98 | shared.value += 1[ invalidates OuterInline, Inner] + | ^^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:91:9 + | +91 | def Inner(once content: () -> None): + | ^^^^^ +info: Source + --> main2.py:98:53 + | +98 | shared.value += 1[ invalidates OuterInline, Inner] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:102:5 + | +102 | def Themed(): + | ^^^^^^ +info: Source + --> main2.py:105:65 + | +105 | Button("click", on_click=lambda: CLICKS.set(1)[ invalidates Themed, Other, …]) + | ^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:109:5 + | +109 | def Other(): + | ^^^^^ +info: Source + --> main2.py:105:73 + | +105 | Button("click", on_click=lambda: CLICKS.set(1)[ invalidates Themed, Other, …]) + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:102:5 + | +102 | def Themed(): + | ^^^^^^ +info: Source + --> main2.py:114:32 + | +114 | CLICKS.set(0)[ invalidates Themed, Other, …] + | ^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:109:5 + | +109 | def Other(): + | ^^^^^ +info: Source + --> main2.py:114:40 + | +114 | CLICKS.set(0)[ invalidates Themed, Other, …] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Child(count: State[int]): + | ^^^^^ +info: Source + --> main2.py:121:39 + | +121 | count.value += 1[ invalidates Child, Editor, Host, Remote, …] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:118:5 + | +118 | def Editor(count: State[int]): + | ^^^^^^ +info: Source + --> main2.py:121:46 + | +121 | count.value += 1[ invalidates Child, Editor, Host, Remote, …] + | ^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:125:5 + | +125 | def Host(): + | ^^^^ +info: Source + --> main2.py:121:54 + | +121 | count.value += 1[ invalidates Child, Editor, Host, Remote, …] + | ^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> remote.by:6:5 + | +6 | def Remote(cell: State[int]): + | ^^^^^^ +info: Source + --> main2.py:121:60 + | +121 | count.value += 1[ invalidates Child, Editor, Host, Remote, …] + | ^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:118:5 + | +118 | def Editor(count: State[int]): + | ^^^^^^ +info: Source + --> main2.py:131:39 + | +131 | count.value += 1[ invalidates Editor, Host, Remote] + | ^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:125:5 + | +125 | def Host(): + | ^^^^ +info: Source + --> main2.py:131:47 + | +131 | count.value += 1[ invalidates Editor, Host, Remote] + | ^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> remote.by:6:5 + | +6 | def Remote(cell: State[int]): + | ^^^^^^ +info: Source + --> main2.py:131:53 + | +131 | count.value += 1[ invalidates Editor, Host, Remote] + | ^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:159:5 + | +159 | def Dashboard(model: Model): + | ^^^^^^^^^ +info: Source + --> main2.py:161:70 + | +161 | Button("reset", on_click=lambda: model.count.set(0)[ invalidates Dashboard, …]) + | ^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:159:5 + | +159 | def Dashboard(model: Model): + | ^^^^^^^^^ +info: Source + --> main2.py:164:34 + | +164 | cell.set(2)[ invalidates Dashboard, …] + | ^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:168:5 + | +168 | def Card(count: State[int], once content: () -> None): + | ^^^^ +info: Source + --> main2.py:173:41 + | +173 | expanded.set(True)[ invalidates Card, Inline, Mid, Top, …] + | ^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:179:5 + | +179 | def Inline(): + | ^^^^^^ +info: Source + --> main2.py:173:47 + | +173 | expanded.set(True)[ invalidates Card, Inline, Mid, Top, …] + | ^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:189:13 + | +189 | private def Mid(count: State[int], once content: () -> None): + | ^^^ +info: Source + --> main2.py:173:55 + | +173 | expanded.set(True)[ invalidates Card, Inline, Mid, Top, …] + | ^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:199:5 + | +199 | def Top(): + | ^^^ +info: Source + --> main2.py:173:60 + | +173 | expanded.set(True)[ invalidates Card, Inline, Mid, Top, …] + | ^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Child(count: State[int]): + | ^^^^^ +info: Source + --> main2.py:175:35 + | +175 | count.set(0)[ invalidates Child, Card, Inline, Mid, Top, …] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:168:5 + | +168 | def Card(count: State[int], once content: () -> None): + | ^^^^ +info: Source + --> main2.py:175:42 + | +175 | count.set(0)[ invalidates Child, Card, Inline, Mid, Top, …] + | ^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:179:5 + | +179 | def Inline(): + | ^^^^^^ +info: Source + --> main2.py:175:48 + | +175 | count.set(0)[ invalidates Child, Card, Inline, Mid, Top, …] + | ^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:189:13 + | +189 | private def Mid(count: State[int], once content: () -> None): + | ^^^ +info: Source + --> main2.py:175:56 + | +175 | count.set(0)[ invalidates Child, Card, Inline, Mid, Top, …] + | ^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:199:5 + | +199 | def Top(): + | ^^^ +info: Source + --> main2.py:175:61 + | +175 | count.set(0)[ invalidates Child, Card, Inline, Mid, Top, …] + | ^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Child(count: State[int]): + | ^^^^^ +info: Source + --> main2.py:185:39 + | +185 | count.value += 1[ invalidates Child, Card, Inline] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:168:5 + | +168 | def Card(count: State[int], once content: () -> None): + | ^^^^ +info: Source + --> main2.py:185:46 + | +185 | count.value += 1[ invalidates Child, Card, Inline] + | ^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:179:5 + | +179 | def Inline(): + | ^^^^^^ +info: Source + --> main2.py:185:52 + | +185 | count.value += 1[ invalidates Child, Card, Inline] + | ^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:168:5 + | +168 | def Card(count: State[int], once content: () -> None): + | ^^^^ +info: Source + --> main2.py:195:37 + | +195 | seen.set(True)[ invalidates Card, Mid, Top] + | ^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:189:13 + | +189 | private def Mid(count: State[int], once content: () -> None): + | ^^^ +info: Source + --> main2.py:195:43 + | +195 | seen.set(True)[ invalidates Card, Mid, Top] + | ^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:199:5 + | +199 | def Top(): + | ^^^ +info: Source + --> main2.py:195:48 + | +195 | seen.set(True)[ invalidates Card, Mid, Top] + | ^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:168:5 + | +168 | def Card(count: State[int], once content: () -> None): + | ^^^^ +info: Source + --> main2.py:206:39 + | +206 | count.value += 1[ invalidates Card, Mid, Top] + | ^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:189:13 + | +189 | private def Mid(count: State[int], once content: () -> None): + | ^^^ +info: Source + --> main2.py:206:45 + | +206 | count.value += 1[ invalidates Card, Mid, Top] + | ^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:199:5 + | +199 | def Top(): + | ^^^ +info: Source + --> main2.py:206:50 + | +206 | count.value += 1[ invalidates Card, Mid, Top] + | ^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:168:5 + | +168 | def Card(count: State[int], once content: () -> None): + | ^^^^ +info: Source + --> main2.py:208:37 + | +208 | flag.set(True)[ invalidates Card, Mid, Top] + | ^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:189:13 + | +189 | private def Mid(count: State[int], once content: () -> None): + | ^^^ +info: Source + --> main2.py:208:43 + | +208 | flag.set(True)[ invalidates Card, Mid, Top] + | ^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:199:5 + | +199 | def Top(): + | ^^^ +info: Source + --> main2.py:208:48 + | +208 | flag.set(True)[ invalidates Card, Mid, Top] + | ^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Child(count: State[int]): + | ^^^^^ +info: Source + --> main2.py:219:43 + | +219 | inner.value += 1[ invalidates Child, InBlock, twice] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:212:5 + | +212 | def InBlock(): + | ^^^^^^^ +info: Source + --> main2.py:219:50 + | +219 | inner.value += 1[ invalidates Child, InBlock, twice] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:215:13 + | +215 | let twice = derived(lambda: inner.value * 2) + | ^^^^^ +info: Source + --> main2.py:219:59 + | +219 | inner.value += 1[ invalidates Child, InBlock, twice] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:223:5 + | +223 | def WriterAlias(): + | ^^^^^^^^^^^ +info: Source + --> main2.py:228:35 + | +228 | alias.set(5)[ invalidates WriterAlias] + | ^^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:237:5 + | +237 | def ReaderAlias(): + | ^^^^^^^^^^^ +info: Source + --> main2.py:242:39 + | +242 | count.value += 1[ invalidates ReaderAlias] + | ^^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Child(count: State[int]): + | ^^^^^ +info: Source + --> main2.py:259:39 + | +259 | count.value += 1[ invalidates Child, Editor] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:118:5 + | +118 | def Editor(count: State[int]): + | ^^^^^^ +info: Source + --> main2.py:259:46 + | +259 | count.value += 1[ invalidates Child, Editor] + | ^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:268:17 + | +268 | rt.set_root(root) + | ^^^^ +info: Source + --> main2.py:267:39 + | +267 | c.value += 1[ invalidates root] + | ^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Child(count: State[int]): + | ^^^^^ +info: Source + --> main2.py:276:34 + | +276 | cell.set(1)[ invalidates Child, …] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:280:13 + | +280 | let t = compose_test: + | ^^^^^^^^^^^^ +info: Source + --> main2.py:284:43 + | +284 | count.value += 1[ invalidates root] + | ^^^^ diff --git a/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_parameter_stability.snap b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_parameter_stability.snap new file mode 100644 index 0000000000..8e1d351970 --- /dev/null +++ b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_parameter_stability.snap @@ -0,0 +1,27 @@ +--- +source: crates/ty_ide/src/inlay_hints.rs +expression: "test.inlay_hints_with_settings(&InlayHintSettings\n{ parameter_stability: true, ..InlayHintSettings::none() })" +--- + +from basedpython_ui import State, StateList, composable + +frozen data class Todo: + title: str + +data class Draft: + title: str + +@composable +def TodoList( + [unstable ]items: list[str], + view: list[out str], + todos: StateList[Todo], + [unstable ]draft: Draft, + todo: Todo, + count: State[int], + on_click: () -> None, + untyped, + [unstable ]table: dict[str, int] = {}, +): ... + +def helper(items: list[str]): ... diff --git a/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_ui_counter_example.snap b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_ui_counter_example.snap new file mode 100644 index 0000000000..5d3eb4a3d8 --- /dev/null +++ b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_ui_counter_example.snap @@ -0,0 +1,79 @@ +--- +source: crates/ty_ide/src/inlay_hints.rs +expression: test.inlay_hints_with_settings(&basedpython_ui_settings()) +--- + +from basedpython_ui import composable, state, Column, Row, Text, Button, Modifier, Alignment, run_app + + +@composable +def Counter(step: int = 1)[ reads count]: + let count = state(0) # State[int], remembered for this scope's lifetime + Column(Modifier().padding(16)): + Text(f"count = {count.value}") # a tracked read: this scope now depends on `count` + Row: + Button("-", enabled=count.value > 0): + count.value -= step[ invalidates Counter] # the block binds `on_click`; a write invalidates readers + Button("+"): + count.value += step[ invalidates Counter] + Button("reset", enabled=count.value != 0): + count.value = 0[ invalidates Counter] + if count.value > 9: + Text("that is a lot", modifier=align(Alignment.Center)) # `align` comes from the ColumnScope receiver + + +@composable +def App(): + Column: + Counter() + Counter(step=5) + + +def main(): + run_app("counter"): + App() + +--------------------------------------------- +info[inlay-hint-location]: Inlay Hint Target + --> main.by:7:9 + | +7 | let count = state(0) # State[int], remembered for this scope's lifetime + | ^^^^^ +info: Source + --> main2.py:6:35 + | +6 | def Counter(step: int = 1)[ reads count]: + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:6:5 + | +6 | def Counter(step: int = 1): + | ^^^^^^^ +info: Source + --> main2.py:12:50 + | +12 | count.value -= step[ invalidates Counter] # the block binds `on_click`; a write invalidates readers + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:6:5 + | +6 | def Counter(step: int = 1): + | ^^^^^^^ +info: Source + --> main2.py:14:50 + | +14 | count.value += step[ invalidates Counter] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:6:5 + | +6 | def Counter(step: int = 1): + | ^^^^^^^ +info: Source + --> main2.py:16:46 + | +16 | count.value = 0[ invalidates Counter] + | ^^^^^^^ diff --git a/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_ui_form_example.snap b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_ui_form_example.snap new file mode 100644 index 0000000000..de38698ff3 --- /dev/null +++ b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_ui_form_example.snap @@ -0,0 +1,395 @@ +--- +source: crates/ty_ide/src/inlay_hints.rs +expression: test.inlay_hints_with_settings(&basedpython_ui_settings()) +--- + +from basedpython_ui import composable, state, derived, Column, Row, Text, Button, TextField, Modifier, TextStyle, run_app + + +let ERROR_STYLE = TextStyle(color="#b00020") + + +frozen data class Signup: + name: str + email: str + + +def validate_name(value: str) -> str?: + return "name is required" if value.strip() == "" else None + + +def validate_email(value: str) -> str?: + if "@" not in value or value.startswith("@"): + return "enter a valid e-mail address" + return None + + +@composable +def Field(label: str, value: str, error: str?, on_change: (str) -> None): + Column(Modifier().padding(4)): + Text(label) + TextField(value, on_change=on_change) + if error is not None: + Text(error, style=ERROR_STYLE) + + +@composable +def SignupForm(on_submit: (Signup) -> None)[ reads name, email, submitted, name_error, email_error, can_submit]: + let name = state("") + let email = state("") + let submitted = state(False) + # derived values are recomputed only when a dependency changes, and never observed half-updated + let name_error = derived(lambda: validate_name(name.value))[ depends on name] + let email_error = derived(lambda: validate_email(email.value))[ depends on email] + let can_submit = derived(lambda: name_error.value is None and email_error.value is None)[ depends on name_error, email_error] + + Column(Modifier().padding(16)): + Field("name", name.value, name_error.value if submitted.value else None): + name.value = it[ invalidates Field, SignupForm, name_error, can_submit, …] + Field("e-mail", email.value, email_error.value if submitted.value else None): + email.value = it[ invalidates Field, SignupForm, email_error, can_submit, …] + Row: + Button("sign up", enabled=can_submit.value or not submitted.value): + submitted.value = True[ invalidates Field, SignupForm, …] + if can_submit.value: + on_submit(Signup(name.value, email.value)) + Button("clear"): + name.value = ""[ invalidates Field, SignupForm, name_error, can_submit, …] + email.value = ""[ invalidates Field, SignupForm, email_error, can_submit, …] + submitted.value = False[ invalidates Field, SignupForm, …] + + +def main(): + run_app("sign up"): + SignupForm(on_submit=lambda s: print("welcome", s.name, s.email)) + +--------------------------------------------- +info[inlay-hint-location]: Inlay Hint Target + --> main.by:34:9 + | +34 | let name = state("") + | ^^^^ +info: Source + --> main2.py:33:52 + | +33 | def SignupForm(on_submit: (Signup) -> None)[ reads name, email, submitted, name_error, email_error, can_submit]: + | ^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:35:9 + | +35 | let email = state("") + | ^^^^^ +info: Source + --> main2.py:33:58 + | +33 | def SignupForm(on_submit: (Signup) -> None)[ reads name, email, submitted, name_error, email_error, can_submit]: + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:36:9 + | +36 | let submitted = state(False) + | ^^^^^^^^^ +info: Source + --> main2.py:33:65 + | +33 | def SignupForm(on_submit: (Signup) -> None)[ reads name, email, submitted, name_error, email_error, can_submit]: + | ^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:38:9 + | +38 | let name_error = derived(lambda: validate_name(name.value)) + | ^^^^^^^^^^ +info: Source + --> main2.py:33:76 + | +33 | def SignupForm(on_submit: (Signup) -> None)[ reads name, email, submitted, name_error, email_error, can_submit]: + | ^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:39:9 + | +39 | let email_error = derived(lambda: validate_email(email.value)) + | ^^^^^^^^^^^ +info: Source + --> main2.py:33:88 + | +33 | def SignupForm(on_submit: (Signup) -> None)[ reads name, email, submitted, name_error, email_error, can_submit]: + | ^^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:40:9 + | +40 | let can_submit = derived(lambda: name_error.value is None and email_error.value is None) + | ^^^^^^^^^^ +info: Source + --> main2.py:33:101 + | +33 | def SignupForm(on_submit: (Signup) -> None)[ reads name, email, submitted, name_error, email_error, can_submit]: + | ^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:34:9 + | +34 | let name = state("") + | ^^^^ +info: Source + --> main2.py:38:77 + | +38 | let name_error = derived(lambda: validate_name(name.value))[ depends on name] + | ^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:35:9 + | +35 | let email = state("") + | ^^^^^ +info: Source + --> main2.py:39:80 + | +39 | let email_error = derived(lambda: validate_email(email.value))[ depends on email] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:38:9 + | +38 | let name_error = derived(lambda: validate_name(name.value)) + | ^^^^^^^^^^ +info: Source + --> main2.py:40:106 + | +40 | let can_submit = derived(lambda: name_error.value is None and email_error.value is None)[ depends on name_error, email_error] + | ^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:39:9 + | +39 | let email_error = derived(lambda: validate_email(email.value)) + | ^^^^^^^^^^^ +info: Source + --> main2.py:40:118 + | +40 | let can_submit = derived(lambda: name_error.value is None and email_error.value is None)[ depends on name_error, email_error] + | ^^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Field(label: str, value: str, error: str?, on_change: (str) -> None): + | ^^^^^ +info: Source + --> main2.py:44:42 + | +44 | name.value = it[ invalidates Field, SignupForm, name_error, can_submit, …] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:33:5 + | +33 | def SignupForm(on_submit: (Signup) -> None): + | ^^^^^^^^^^ +info: Source + --> main2.py:44:49 + | +44 | name.value = it[ invalidates Field, SignupForm, name_error, can_submit, …] + | ^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:38:9 + | +38 | let name_error = derived(lambda: validate_name(name.value)) + | ^^^^^^^^^^ +info: Source + --> main2.py:44:61 + | +44 | name.value = it[ invalidates Field, SignupForm, name_error, can_submit, …] + | ^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:40:9 + | +40 | let can_submit = derived(lambda: name_error.value is None and email_error.value is None) + | ^^^^^^^^^^ +info: Source + --> main2.py:44:73 + | +44 | name.value = it[ invalidates Field, SignupForm, name_error, can_submit, …] + | ^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Field(label: str, value: str, error: str?, on_change: (str) -> None): + | ^^^^^ +info: Source + --> main2.py:46:43 + | +46 | email.value = it[ invalidates Field, SignupForm, email_error, can_submit, …] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:33:5 + | +33 | def SignupForm(on_submit: (Signup) -> None): + | ^^^^^^^^^^ +info: Source + --> main2.py:46:50 + | +46 | email.value = it[ invalidates Field, SignupForm, email_error, can_submit, …] + | ^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:39:9 + | +39 | let email_error = derived(lambda: validate_email(email.value)) + | ^^^^^^^^^^^ +info: Source + --> main2.py:46:62 + | +46 | email.value = it[ invalidates Field, SignupForm, email_error, can_submit, …] + | ^^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:40:9 + | +40 | let can_submit = derived(lambda: name_error.value is None and email_error.value is None) + | ^^^^^^^^^^ +info: Source + --> main2.py:46:75 + | +46 | email.value = it[ invalidates Field, SignupForm, email_error, can_submit, …] + | ^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Field(label: str, value: str, error: str?, on_change: (str) -> None): + | ^^^^^ +info: Source + --> main2.py:49:53 + | +49 | submitted.value = True[ invalidates Field, SignupForm, …] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:33:5 + | +33 | def SignupForm(on_submit: (Signup) -> None): + | ^^^^^^^^^^ +info: Source + --> main2.py:49:60 + | +49 | submitted.value = True[ invalidates Field, SignupForm, …] + | ^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Field(label: str, value: str, error: str?, on_change: (str) -> None): + | ^^^^^ +info: Source + --> main2.py:53:46 + | +53 | name.value = ""[ invalidates Field, SignupForm, name_error, can_submit, …] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:33:5 + | +33 | def SignupForm(on_submit: (Signup) -> None): + | ^^^^^^^^^^ +info: Source + --> main2.py:53:53 + | +53 | name.value = ""[ invalidates Field, SignupForm, name_error, can_submit, …] + | ^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:38:9 + | +38 | let name_error = derived(lambda: validate_name(name.value)) + | ^^^^^^^^^^ +info: Source + --> main2.py:53:65 + | +53 | name.value = ""[ invalidates Field, SignupForm, name_error, can_submit, …] + | ^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:40:9 + | +40 | let can_submit = derived(lambda: name_error.value is None and email_error.value is None) + | ^^^^^^^^^^ +info: Source + --> main2.py:53:77 + | +53 | name.value = ""[ invalidates Field, SignupForm, name_error, can_submit, …] + | ^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Field(label: str, value: str, error: str?, on_change: (str) -> None): + | ^^^^^ +info: Source + --> main2.py:54:47 + | +54 | email.value = ""[ invalidates Field, SignupForm, email_error, can_submit, …] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:33:5 + | +33 | def SignupForm(on_submit: (Signup) -> None): + | ^^^^^^^^^^ +info: Source + --> main2.py:54:54 + | +54 | email.value = ""[ invalidates Field, SignupForm, email_error, can_submit, …] + | ^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:39:9 + | +39 | let email_error = derived(lambda: validate_email(email.value)) + | ^^^^^^^^^^^ +info: Source + --> main2.py:54:66 + | +54 | email.value = ""[ invalidates Field, SignupForm, email_error, can_submit, …] + | ^^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:40:9 + | +40 | let can_submit = derived(lambda: name_error.value is None and email_error.value is None) + | ^^^^^^^^^^ +info: Source + --> main2.py:54:79 + | +54 | email.value = ""[ invalidates Field, SignupForm, email_error, can_submit, …] + | ^^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:5 + | +24 | def Field(label: str, value: str, error: str?, on_change: (str) -> None): + | ^^^^^ +info: Source + --> main2.py:55:54 + | +55 | submitted.value = False[ invalidates Field, SignupForm, …] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:33:5 + | +33 | def SignupForm(on_submit: (Signup) -> None): + | ^^^^^^^^^^ +info: Source + --> main2.py:55:61 + | +55 | submitted.value = False[ invalidates Field, SignupForm, …] + | ^^^^^^^^^^ diff --git a/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_ui_todo_example.snap b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_ui_todo_example.snap new file mode 100644 index 0000000000..a3ae3fd4da --- /dev/null +++ b/crates/ty_ide/src/snapshots/ty_ide__inlay_hints__tests__basedpython_ui_todo_example.snap @@ -0,0 +1,239 @@ +--- +source: crates/ty_ide/src/inlay_hints.rs +expression: test.inlay_hints_with_settings(&basedpython_ui_settings()) +--- + +from basedpython_ui import ( + composable, state, state_list, derived, Column, Row, Text, Button, Checkbox, TextField, Modifier, run_app, +) + + +frozen data class Todo: # immutable: the only way to change a todo is to replace it in the list + id: int + title: str + done: bool = False + + +@composable +def TodoRow(todo: Todo, on_delete: () -> None, on_toggle: (bool) -> None): + Row(Modifier().padding(4)): + Checkbox(todo.done, on_change=on_toggle) + Text(todo.title, modifier=weight(1.0)) + Button("x", on_click=on_delete) + + +@composable +def TodoApp()[ reads todos, draft, remaining]: + let todos = state_list([Todo(1, "write the runtime"), Todo(2, "ship it")]) # StateList[Todo]: observable + let draft = state("") + let next_id = state(3) + let remaining = derived(lambda: sum(1 for t in todos if not t.done)) # ⟨depends on todos⟩[ depends on todos] + + Column(Modifier().padding(12)): + Text(f"{remaining.value} of {len(todos)} remaining") + Row: + TextField(draft.value, placeholder="what needs doing?", modifier=weight(1.0)): + draft.value = it[ invalidates TodoRow, TodoApp] # `it: str`, the new text + Button("add", enabled=draft.value != ""): + todos.append(Todo(next_id.value, draft.value))[ invalidates TodoRow, TodoApp, remaining] + next_id.value += 1[ invalidates nothing] + draft.value = ""[ invalidates TodoRow, TodoApp] + todos.each_indexed(key=lambda todo: todo.id): # keyed children; `it` is this row + let row = it # the inner block below has its own `it` + TodoRow(row.item, on_delete=lambda: todos.remove_at(row.index)[ invalidates TodoRow, TodoApp, remaining]): + todos[row.index] = Todo(row.item.id, row.item.title, it)[ invalidates TodoRow, TodoApp, remaining] # `it: bool` from `on_toggle` + if len(todos) == 0: + Text("nothing to do") + + +def main(): + run_app("todos"): + TodoApp() + +--------------------------------------------- +info[inlay-hint-location]: Inlay Hint Target + --> main.by:23:9 + | +23 | let todos = state_list([Todo(1, "write the runtime"), Todo(2, "ship it")]) # StateList[Todo]: observable + | ^^^^^ +info: Source + --> main2.py:22:22 + | +22 | def TodoApp()[ reads todos, draft, remaining]: + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:24:9 + | +24 | let draft = state("") + | ^^^^^ +info: Source + --> main2.py:22:29 + | +22 | def TodoApp()[ reads todos, draft, remaining]: + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:26:9 + | +26 | let remaining = derived(lambda: sum(1 for t in todos if not t.done)) # ⟨depends on todos⟩ + | ^^^^^^^^^ +info: Source + --> main2.py:22:36 + | +22 | def TodoApp()[ reads todos, draft, remaining]: + | ^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:23:9 + | +23 | let todos = state_list([Todo(1, "write the runtime"), Todo(2, "ship it")]) # StateList[Todo]: observable + | ^^^^^ +info: Source + --> main2.py:26:114 + | +26 | let remaining = derived(lambda: sum(1 for t in todos if not t.done)) # ⟨depends on todos⟩[ depends on todos] + | ^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:14:5 + | +14 | def TodoRow(todo: Todo, on_delete: () -> None, on_toggle: (bool) -> None): + | ^^^^^^^ +info: Source + --> main2.py:32:47 + | +32 | … draft.value = it[ invalidates TodoRow, TodoApp] # `it: str`, the new text + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:22:5 + | +22 | def TodoApp(): + | ^^^^^^^ +info: Source + --> main2.py:32:56 + | +32 | … draft.value = it[ invalidates TodoRow, TodoApp] # `it: str`, the new text + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:14:5 + | +14 | def TodoRow(todo: Todo, on_delete: () -> None, on_toggle: (bool) -> None): + | ^^^^^^^ +info: Source + --> main2.py:34:77 + | +34 | todos.append(Todo(next_id.value, draft.value))[ invalidates TodoRow, TodoApp, remaining] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:22:5 + | +22 | def TodoApp(): + | ^^^^^^^ +info: Source + --> main2.py:34:86 + | +34 | todos.append(Todo(next_id.value, draft.value))[ invalidates TodoRow, TodoApp, remaining] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:26:9 + | +26 | let remaining = derived(lambda: sum(1 for t in todos if not t.done)) # ⟨depends on todos⟩ + | ^^^^^^^^^ +info: Source + --> main2.py:34:95 + | +34 | todos.append(Todo(next_id.value, draft.value))[ invalidates TodoRow, TodoApp, remaining] + | ^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:14:5 + | +14 | def TodoRow(todo: Todo, on_delete: () -> None, on_toggle: (bool) -> None): + | ^^^^^^^ +info: Source + --> main2.py:36:47 + | +36 | draft.value = ""[ invalidates TodoRow, TodoApp] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:22:5 + | +22 | def TodoApp(): + | ^^^^^^^ +info: Source + --> main2.py:36:56 + | +36 | draft.value = ""[ invalidates TodoRow, TodoApp] + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:14:5 + | +14 | def TodoRow(todo: Todo, on_delete: () -> None, on_toggle: (bool) -> None): + | ^^^^^^^ +info: Source + --> main2.py:39:89 + | +39 | TodoRow(row.item, on_delete=lambda: todos.remove_at(row.index)[ invalidates TodoRow, TodoApp, remaining]): + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:22:5 + | +22 | def TodoApp(): + | ^^^^^^^ +info: Source + --> main2.py:39:98 + | +39 | TodoRow(row.item, on_delete=lambda: todos.remove_at(row.index)[ invalidates TodoRow, TodoApp, remaining]): + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:26:9 + | +26 | let remaining = derived(lambda: sum(1 for t in todos if not t.done)) # ⟨depends on todos⟩ + | ^^^^^^^^^ +info: Source + --> main2.py:39:107 + | +39 | TodoRow(row.item, on_delete=lambda: todos.remove_at(row.index)[ invalidates TodoRow, TodoApp, remaining]): + | ^^^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:14:5 + | +14 | def TodoRow(todo: Todo, on_delete: () -> None, on_toggle: (bool) -> None): + | ^^^^^^^ +info: Source + --> main2.py:40:87 + | +40 | … todos[row.index] = Todo(row.item.id, row.item.title, it)[ invalidates TodoRow, TodoApp, remaining] # `it: bool` from `on_… + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:22:5 + | +22 | def TodoApp(): + | ^^^^^^^ +info: Source + --> main2.py:40:96 + | +40 | … todos[row.index] = Todo(row.item.id, row.item.title, it)[ invalidates TodoRow, TodoApp, remaining] # `it: bool` from `on_… + | ^^^^^^^ + +info[inlay-hint-location]: Inlay Hint Target + --> main.by:26:9 + | +26 | let remaining = derived(lambda: sum(1 for t in todos if not t.done)) # ⟨depends on todos⟩ + | ^^^^^^^^^ +info: Source + --> main2.py:40:105 + | +40 | … todos[row.index] = Todo(row.item.id, row.item.title, it)[ invalidates TodoRow, TodoApp, remaining] # `it: bool` from `on_… + | ^^^^^^^^^ diff --git a/crates/ty_module_resolver/src/module.rs b/crates/ty_module_resolver/src/module.rs index a91ae6a623..93d810118e 100644 --- a/crates/ty_module_resolver/src/module.rs +++ b/crates/ty_module_resolver/src/module.rs @@ -433,6 +433,17 @@ pub enum KnownModule { SqlalchemyOrmBase, #[strum(serialize = "sqlalchemy.orm.decl_api")] SqlalchemyOrmDeclApi, + // basedpython-ui framework modules. unlike the third-party modules above + // these are recognised on *any* search path: the framework is developed in + // place as a first-party package, and installed as a third-party one + #[strum(serialize = "basedpython_ui")] + BasedpythonUi, + #[strum(serialize = "basedpython_ui.runtime")] + BasedpythonUiRuntime, + #[strum(serialize = "basedpython_ui.widgets")] + BasedpythonUiWidgets, + #[strum(serialize = "basedpython_ui.app")] + BasedpythonUiApp, } impl KnownModule { @@ -493,6 +504,10 @@ impl KnownModule { Self::PytestMarkStructures => "_pytest.mark.structures", Self::SqlalchemyOrmBase => "sqlalchemy.orm.base", Self::SqlalchemyOrmDeclApi => "sqlalchemy.orm.decl_api", + Self::BasedpythonUi => "basedpython_ui", + Self::BasedpythonUiRuntime => "basedpython_ui.runtime", + Self::BasedpythonUiWidgets => "basedpython_ui.widgets", + Self::BasedpythonUiApp => "basedpython_ui.app", } } @@ -504,7 +519,11 @@ impl KnownModule { fn try_from_search_path_and_name(search_path: &SearchPath, name: &ModuleName) -> Option { let known_module = Self::from_str(name.as_str()).ok()?; - let is_expected_search_path = if known_module.is_third_party() { + let is_expected_search_path = if known_module.is_framework() { + // a framework module is accepted wherever it resolves from: it is + // developed in place (first-party) as well as installed + true + } else if known_module.is_third_party() { search_path.can_contain_third_party_code() } else { search_path.is_standard_library() @@ -513,6 +532,21 @@ impl KnownModule { is_expected_search_path.then_some(known_module) } + /// basedpython: return `true` if this module belongs to a framework that is + /// recognised on every search path — first-party as well as third-party — + /// so it can be developed in place. Every framework module is also + /// [`is_third_party`](Self::is_third_party): it is never part of the + /// standard library + const fn is_framework(self) -> bool { + matches!( + self, + Self::BasedpythonUi + | Self::BasedpythonUiRuntime + | Self::BasedpythonUiWidgets + | Self::BasedpythonUiApp + ) + } + /// Return `true` if this module is provided by a supported third-party package. pub const fn is_third_party(self) -> bool { match self { @@ -534,7 +568,11 @@ impl KnownModule { | Self::PytestFixtures | Self::PytestMarkStructures | Self::SqlalchemyOrmBase - | Self::SqlalchemyOrmDeclApi => true, + | Self::SqlalchemyOrmDeclApi + | Self::BasedpythonUi + | Self::BasedpythonUiRuntime + | Self::BasedpythonUiWidgets + | Self::BasedpythonUiApp => true, Self::Builtins | Self::Enum | Self::Types @@ -634,4 +672,49 @@ mod tests { ); } } + + /// basedpython: a framework module is recognised from a first-party search + /// path as well as from site-packages, unlike an ordinary third-party one + #[test] + fn framework_module_recognised_on_every_search_path() { + use ruff_db::Db as _; + + use crate::testing::{TestCase, TestCaseBuilder}; + + let TestCase { + db, + src, + site_packages, + .. + } = TestCaseBuilder::new().build(); + let first_party = SearchPath::first_party(db.system(), src) + .expect("first-party search path should be valid"); + let site_packages = SearchPath::site_packages(db.system(), site_packages) + .expect("site-packages search path should be valid"); + + for module in KnownModule::iter().filter(|module| module.is_framework()) { + let module_name = module.name(); + assert!( + module.is_third_party(), + "`{module_name}` is not standard library" + ); + assert_eq!( + KnownModule::try_from_search_path_and_name(&first_party, &module_name), + Some(module), + "`{module_name}` should be recognised on a first-party search path" + ); + assert_eq!( + KnownModule::try_from_search_path_and_name(&site_packages, &module_name), + Some(module), + "`{module_name}` should be recognised in site-packages" + ); + } + + // an ordinary third-party module keeps its restriction + let pydantic = KnownModule::PydanticMain; + assert_eq!( + KnownModule::try_from_search_path_and_name(&first_party, &pydantic.name()), + None + ); + } } diff --git a/crates/ty_python_core/src/builder.rs b/crates/ty_python_core/src/builder.rs index de30eb74c0..7cd4a39469 100644 --- a/crates/ty_python_core/src/builder.rs +++ b/crates/ty_python_core/src/builder.rs @@ -2416,10 +2416,20 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { let enclosing = self.current_scope(); // names the block binds locally, excluding any it already declares - // `global` / `nonlocal` (those flow through the general nested path) + // `global` / `nonlocal` (those flow through the general nested path). + // a name the block *declares* — a `let` / `var`, an annotated + // assignment, a nested `def`, its own `it` — is the block's own local + // rather than a write to an enclosing binding: python gives an annotated + // name to the function that annotates it (and rejects `nonlocal` on it), + // and the lowering keeps such a declaration a local of the block let candidates: Vec = self.place_tables[block_scope] .symbols() - .filter(|symbol| symbol.is_bound() && !symbol.is_global() && !symbol.is_nonlocal()) + .filter(|symbol| { + symbol.is_bound() + && !symbol.is_declared() + && !symbol.is_global() + && !symbol.is_nonlocal() + }) .map(|symbol| symbol.name().clone()) .collect(); @@ -4737,6 +4747,20 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { .. }) => self.visit_coalesce_expression(left, right), ast::Expr::Call(_) | ast::Expr::BinOp(_) => { + // basedpython-ui: the callee of a `.set_root()` + // call is inferred standalone, so the scope of the `root` + // argument (a lambda, or a function passed by name) can learn + // whom it was handed to without the enclosing scope's inference + // — which may itself be waiting on that scope, for a lambda's + // return type. The callee of a `derived()` / + // `remember()` call is registered for the same reason: + // the `compute` lambda's scope learns that what it reads is what + // the composition depends on + if let ast::Expr::Call(call) = expr + && is_basedpython_ui_argument_callee(&call.func) + { + self.add_standalone_expression(&call.func); + } walk_expr(self, expr); self.record_exception_checkpoint(); } @@ -4980,6 +5004,25 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> { // type without depending on the enclosing definition inference if let Some(callee) = function_def.trailing_lambda_callee() { self.add_standalone_expression(callee); + // the call's written arguments are standalone too, so what + // they solve for a generic callee — the type of the block's + // `it` — can be read the same way, from the block's own + // scope. an unpacked argument is left out: it is inferred + // with the call, and a call carrying one is not solved for + // the block + if let Some(call) = function_def.trailing_lambda_call() { + for argument in call.arguments.iter_source_order() { + match argument { + ast::ArgOrKeyword::Arg(value) if !value.is_starred_expr() => { + self.add_standalone_expression(value); + } + ast::ArgOrKeyword::Keyword(keyword) if keyword.arg.is_some() => { + self.add_standalone_expression(&keyword.value); + } + ast::ArgOrKeyword::Arg(_) | ast::ArgOrKeyword::Keyword(_) => {} + } + } + } } // Evaluate default args before we visit the body. If the default expression ends @@ -7410,6 +7453,21 @@ fn is_trailing_lambda_value(value: &ast::Expr) -> bool { .is_some_and(ast::ExprStatement::is_trailing_lambda) } +/// basedpython-ui: whether `func` is spelled like the callee of a call whose +/// argument's scope must learn whom it was handed to from a standalone +/// inference of the callee — `.set_root()`, `derived()`, +/// `remember()`. The shapes are syntactic; the checker resolves what +/// the callee is when it reads the inference back +fn is_basedpython_ui_argument_callee(func: &ast::Expr) -> bool { + match func { + ast::Expr::Attribute(attribute) => { + matches!(attribute.attr.as_str(), "set_root" | "derived" | "remember") + } + ast::Expr::Name(name) => matches!(name.id.as_str(), "derived" | "remember"), + _ => false, + } +} + /// Whether constraints learned at a fluid-candidate use in this statement can be read /// back from the statement's standalone inference. Compound statements are excluded /// because inferring them as a standalone unit would re-infer their entire body. diff --git a/crates/ty_python_semantic/resources/lint_docs/content-block-control-flow.md b/crates/ty_python_semantic/resources/lint_docs/content-block-control-flow.md new file mode 100644 index 0000000000..96730ff72b --- /dev/null +++ b/crates/ty_python_semantic/resources/lint_docs/content-block-control-flow.md @@ -0,0 +1,32 @@ +## What it does + +Checks for a `return` inside a `once` content block that is itself written inside another +trailing-lambda block. + +## Why is this bad? + +A `once` block runs exactly once, inline, so a `return` inside it is allowed to leave the enclosing +scope — but only one level: the language propagates a block's `return` to the scope the block is +written in. When that scope is itself a block, the `return` leaves the inner block and stops there; +the enclosing function keeps running, and the returned value is silently discarded. + +(A `break` or `continue` inside any block is already rejected as `break` outside loop: a block is +its own function.) + +## Examples + +```by +def Column(once content: () -> None): + content() + +def Row(once content: () -> None): + content() + +def App(done: bool) -> int: + Column: + Row: + if done: + return 1 # error: [content-block-control-flow] + return 2 # ok: one level, leaves `App` + return 0 +``` diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_ui_block_call.md b/crates/ty_python_semantic/resources/mdtest/basedpython_ui_block_call.md new file mode 100644 index 0000000000..6ef842add5 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_ui_block_call.md @@ -0,0 +1,81 @@ +# basedpython-ui: a call carrying a trailing block binds like an ordinary call + +A statement-level `f(...):` block passes its suite as the call's last argument, and the rest of the +call is still a call: its `context` parameters are filled from the `context` declarations in scope, +and its arguments are conversion sites. A compose-style component declares both — a `context` theme +and a `once` content block — so the block form has to bind exactly like `f(...)` does. + +## a `context` parameter is filled on a block-carrying call + +```by +def Card(title: str, context theme: str, *, once content: () -> None): + content() + +context theme = "dark" + +Card("x"): + pass +``` + +## a missing `context` argument is still reported + +```by +def Card(title: str, context theme: str, *, once content: () -> None): + content() + +Card("x"): # error: [missing-context-argument] + pass +``` + +## an explicit argument still wins over the declaration + +```by +def Card(title: str, context theme: str, *, once content: () -> None): + content() + +context theme = "dark" + +Card("x", theme="light"): + pass +``` + +## a literal argument converts through `__of__` on a block-carrying call + +The argument is inferred against its parameter, so the literal is a conversion site — the same +`__of__` that `Padding(8)` without a block resolves. + +```by +class Dp: + value: float = 0.0 + + @classmethod + def __of__(cls, value: int | float) -> Self: + return cls() + +def Padding(amount: Dp, *, once content: () -> None): + content() + +Padding(8): + pass + +Padding(8.5): + pass +``` + +## a value the target cannot convert is still rejected + +```by + +class Dp: + value: float = 0.0 + + @classmethod + def __of__(cls, value: int | float) -> Self: + return cls() + +def Padding(amount: Dp, *, once content: () -> None): + content() + +Padding("wide"): # error: [invalid-argument-type] + pass +``` diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_ui_block_let.md b/crates/ty_python_semantic/resources/mdtest/basedpython_ui_block_let.md new file mode 100644 index 0000000000..cd4b5f77c4 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_ui_block_let.md @@ -0,0 +1,67 @@ +# basedpython-ui: a declaration inside a trailing-lambda block is the block's own + +A `once` block runs inline, so a plain assignment in it writes through to an enclosing binding of +the same name. A *declaration* — a `let`, a `var`, an annotated assignment — is different: it +introduces the block's own local, exactly as python treats an annotated name inside a nested +function (which it refuses to make `nonlocal`). The enclosing binding is untouched after the block. + +## a `let` inside a `once` block shadows, and does not write through + +```by +def run(once block: () -> None): + block() + +def show(user: str) -> None: + run: + let user = 1 + reveal_type(user) # revealed: 1 + reveal_type(user) # revealed: str + +``` + +## the enclosing binding may be a `match` capture + +```by +async def load(name: str) -> str: + return name.upper() + +async def scope(once block: () -> Awaitable[None]): + await block() + +async def main() -> None: + match "morgan": + case str() as user: + await scope(): + let user = await load("nested") + reveal_type(user) # revealed: str + reveal_type(user) # revealed: "morgan" + +``` + +## an annotated assignment is a declaration too + +```by +def run(once block: () -> None): + block() + +def main() -> None: + total: int = 1 + run: + total: int = 2 + reveal_type(total) # revealed: 1 + +``` + +## a plain assignment still writes through + +```by +def run(once block: () -> None): + block() + +def main() -> None: + total: int = 1 + run: + total = 2 + reveal_type(total) # revealed: 2 + +``` diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_ui_context_before_block.md b/crates/ty_python_semantic/resources/mdtest/basedpython_ui_context_before_block.md new file mode 100644 index 0000000000..9764a7a37c --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_ui_context_before_block.md @@ -0,0 +1,80 @@ +# basedpython-ui: a `context` parameter may precede a trailing callback + +A `context` parameter is filled by keyword, so nothing may follow it that an explicit positional +argument could land on. The one parameter that never takes a positional argument is the callback a +trailing block fills — it is always passed by keyword, as the callee's last parameter — so a +component can declare both a `context` parameter and a content block. + +## the last parameter may be a callable + +```by +def Card(title: str, context theme: str, once content: () -> None): + content() + +context theme = "dark" + +Card("x"): + pass + +def body() -> None: ... + +Card("y", content=body) +``` + +## the callable still binds the block by keyword + +An earlier defaulted parameter keeps its default: the block goes to `content`, not to the next +positional slot. + +```by +def Card(title: str = "untitled", context theme: str = "light", once content: () -> None = lambda: None): + content() + +context theme = "dark" + +Card(): + pass +``` + +## a keyword-only parameter may follow too + +A keyword-only parameter cannot take a positional argument at all, so the rule never had anything to +say about one — with or without the trailing-callback exemption. + +```by +def Card(title: str, context theme: str, *, once content: () -> None): + content() + +context theme = "dark" + +Card("x"): + pass +``` + +## anything else after a `context` parameter is still rejected + +A callable that is not the last parameter is not the trailing callback, and a non-callable last +parameter could take a positional argument. + +```by +# error: [invalid-syntax] "parameter after a `context` parameter must also be `context`" +def f(context b: str, a: int): ... + +# error: [invalid-syntax] "parameter after a `context` parameter must also be `context`" +def g(context b: str, cb: () -> None, a: int): ... # error: [invalid-syntax] +``` + +## an unmarked callable is rejected too + +The exemption is for the callback a trailing block fills, which the call passes by keyword. An +ordinary callable parameter can take a positional argument, and a positional argument written after +a `context` parameter would land on the `context` parameter instead — so a last parameter earns the +exemption only by carrying the `once` / `local` modifier that marks it a borrowed callback. A plain +callable that must follow a `context` parameter can still be written keyword-only. + +```by +# error: [invalid-syntax] "parameter after a `context` parameter must also be `context`" +def Card(title: str, context theme: str, on_click: () -> None): ... + +def Ok(title: str, context theme: str, *, on_click: () -> None): ... +``` diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_ui_generic_block.md b/crates/ty_python_semantic/resources/mdtest/basedpython_ui_generic_block.md new file mode 100644 index 0000000000..3b1611df42 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_ui_generic_block.md @@ -0,0 +1,98 @@ +# basedpython-ui: a block's `it` on a generic callee is typed from the call + +A generic free function's callback parameter mentions the function's own type variables, so what a +block's `it` (and receiver) are is only known once the call's written arguments have solved them. +The block is typed from that solution — as it already is for a bound method, whose receiver carries +its specialization — rather than from the unsolved `T`. A compose-style `each(items):` helper is +exactly this shape. + +## `it` takes the solved type + +```by +def each[T](items: tuple[T, ...], local block: (T) -> None): + for item in items: + block(item) + +def use(names: tuple[str, ...], counts: tuple[int, ...]): + each(names): + reveal_type(it) # revealed: str + + each(counts): + reveal_type(it) # revealed: int +``` + +## the solution is as precise as the argument + +A literal display solves `T` to its literal elements. + +```by +def each[T](items: tuple[T, ...], local block: (T) -> None): + for item in items: + block(item) + +each(("a", "b")): + reveal_type(it) # revealed: "a" | "b" +``` + +## a keyword argument solves it too + +```by +def each[T](items: tuple[T, ...], local block: (T) -> None): + for item in items: + block(item) + +def use(names: tuple[str, ...]): + each(items=names): + reveal_type(it) # revealed: str +``` + +## the receiver takes the solved type + +```by +def with_each[T](items: tuple[T, ...], block: T.() -> None): + for item in items: + item.block() + +def use(names: tuple[str, ...]): + with_each(names): + reveal_type(upper()) # revealed: str +``` + +## the solved `it` fills a `context` parameter + +```by +def each[T](items: tuple[T, ...], local block: (T) -> None): + for item in items: + block(item) + +def show(context label: str): ... + +each(("a", "b")): + show() +``` + +## a bound method still specializes from its receiver + +```by +class Items[T]: + def each(self, local block: (T) -> None): ... + +def use(items: Items[str]): + items.each: + reveal_type(it) # revealed: str +``` + +## an unpacked argument leaves the callee unsolved + +The block is not solved from a call whose arguments cannot be bound statically, so `it` keeps the +declared type variable. + +```by +def each[T](items: tuple[T, ...], local block: (T) -> None): + for item in items: + block(item) + +def use(args: tuple[tuple[str, ...]]): + each(*args): + reveal_type(it) # revealed: T@each +``` diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_ui_lints.md b/crates/ty_python_semantic/resources/mdtest/basedpython_ui_lints.md new file mode 100644 index 0000000000..0d06fb4303 --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_ui_lints.md @@ -0,0 +1,1930 @@ +# basedpython-ui: the composition lints + +A `@composable` function describes a piece of ui as a function of the observables it reads. Its body +and the `once` content blocks written in it (`Column:`, `Row:`) run *while composing*; a handler +block, a lambda, a nested `def` or an effect block written in it runs *later*, in response to an +event. The lints below are all about that distinction, and about what may be held in state. Each +test that needs the framework installs a mock of it in site-packages with exactly the pieces it +uses. + +## `is_deeply_immutable`: what may be held in state + +A value is *deeply immutable* when nothing reachable from it can change after it is created. The +predicate is exposed for tests through `ty_extensions._internal`. + +### the scalars and their literals + +```by +from typing import Literal +from ty_extensions import static_assert +from ty_extensions._internal import is_deeply_immutable + +static_assert(is_deeply_immutable(int)) +static_assert(is_deeply_immutable(float)) +static_assert(is_deeply_immutable(bool)) +static_assert(is_deeply_immutable(str)) +static_assert(is_deeply_immutable(bytes)) +static_assert(is_deeply_immutable(None)) +static_assert(is_deeply_immutable(complex)) +static_assert(is_deeply_immutable(range)) +static_assert(is_deeply_immutable(Literal[1, "a", True])) +``` + +### a container is only as immutable as what it holds + +```by +from ty_extensions import static_assert +from ty_extensions._internal import is_deeply_immutable + +static_assert(is_deeply_immutable(tuple[int, str])) +static_assert(is_deeply_immutable(tuple[int, ...])) +static_assert(not is_deeply_immutable(tuple[int, list[int]])) +static_assert(is_deeply_immutable(frozenset[int])) +static_assert(not is_deeply_immutable(frozenset[tuple[list[int]]])) +static_assert(not is_deeply_immutable(list[int])) +static_assert(not is_deeply_immutable(dict[str, int])) +static_assert(not is_deeply_immutable(set[int])) +static_assert(not is_deeply_immutable(bytearray)) +``` + +### enum members and enum instances + +A basedpython `enum class` counts too: its unit variants are members, and its payload variants are +frozen dataclasses, checked field by field. + +```by +from enum import Enum +from typing import Literal +from ty_extensions import static_assert +from ty_extensions._internal import is_deeply_immutable + +class Color(Enum): + RED = 1 + GREEN = 2 + +static_assert(is_deeply_immutable(Color)) +static_assert(is_deeply_immutable(Literal[Color.RED])) + +enum class Shape: + case Point + case Circle(radius: float) + case Polygon(points: list[float]) + +static_assert(is_deeply_immutable(Shape.Point)) +static_assert(is_deeply_immutable(Shape.Circle)) +static_assert(not is_deeply_immutable(Shape.Polygon)) +``` + +### a record is immutable when it cannot be written and holds only immutable fields + +```by +from dataclasses import dataclass +from typing import NamedTuple +from ty_extensions import static_assert +from ty_extensions._internal import is_deeply_immutable + +frozen data class Todo: + title: str + done: bool = False + +frozen data class Bag: + items: list[int] + +data class Draft: + title: str + +@dataclass(frozen=True) +class Frozen: + x: int + +class Point(NamedTuple): + x: int + y: int + +class Cell(NamedTuple): + items: list[int] + +class Plain: + x: int = 0 + +static_assert(is_deeply_immutable(Todo)) +static_assert(not is_deeply_immutable(Bag)) +static_assert(not is_deeply_immutable(Draft)) +static_assert(is_deeply_immutable(Frozen)) +static_assert(is_deeply_immutable(Point)) +static_assert(not is_deeply_immutable(Cell)) +static_assert(not is_deeply_immutable(Plain)) +static_assert(not is_deeply_immutable(object)) +``` + +### type objects, callables and the observables are stable by identity + +```toml +[environment] +python = "/.venv" +``` + +The framework's observables are handles whose mutations notify, so a `StateList` is stable even +though what it lists is a `list` inside. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export State, StateList, StateDict, Derived, Ambient +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +class State[T]: + value: T + +class StateList[T]: ... +class StateDict[K, V]: ... + +class Derived[T]: + value: T + +class Ambient[T]: + current: T +``` + +```by +from typing import Callable +from ty_extensions import static_assert +from ty_extensions._internal import is_deeply_immutable +from basedpython_ui import State, StateList, StateDict, Derived, Ambient + +static_assert(is_deeply_immutable(type[int])) +static_assert(is_deeply_immutable(type)) +static_assert(is_deeply_immutable(Callable[[], None])) +static_assert(is_deeply_immutable(State[int])) +static_assert(is_deeply_immutable(StateList[list[int]])) +static_assert(is_deeply_immutable(StateDict[str, int])) +static_assert(is_deeply_immutable(Derived[int])) +static_assert(is_deeply_immutable(Ambient[str])) +``` + +### unions, gradual types and type variables + +A union is immutable when every member is; a gradual type says nothing, so it is; a type variable +answers through its bound, and one without a bound stands for whatever the caller passes, which is +checked where the call is solved. + +```by +from typing import Any +from ty_extensions import static_assert +from ty_extensions._internal import is_deeply_immutable + +static_assert(is_deeply_immutable(int | str | None)) +static_assert(not is_deeply_immutable(int | list[int])) +static_assert(is_deeply_immutable(Any)) + +def unbounded[T](value: T): + static_assert(is_deeply_immutable(T)) + +def bounded[T: int](value: T): + static_assert(is_deeply_immutable(T)) + +def bounded_by_a_list[T: list[int]](value: T): + static_assert(not is_deeply_immutable(T)) +``` + +## `mutable-state-value`: a value held in state must be deeply immutable + +A `State` notifies its readers when it is assigned; a change made *inside* the held value notifies +nobody. So the initial value of `state(...)` / `State(...)`, the elements of `state_list(...)` / +`StateList(...)`, the value a `derived` / `remember` lambda computes, and every value written into +an observable afterwards must be deeply immutable. The message names the type that cannot be held. + +### what a construction call holds + +```toml +[environment] +python = "/.venv" +``` + +It is read off the call's solved result — `state([1, 2])` holds the `list[int]` its +`State[list[int]]` says it does — and reported at the argument. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export State, StateList, state, state_list, derived, remember +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +from collections.abc import Iterable + +class State[T]: + value: T + def __init__(self, initial: T) -> None: ... + +class StateList[T]: + def __init__(self, initial: Iterable[T] = ()) -> None: ... + +class Derived[T]: + value: T + +def state[T](initial: T) -> State[T]: ... +def state_list[T](initial: Iterable[T] = ()) -> StateList[T]: ... +def derived[T](compute: () -> T) -> Derived[T]: ... +def remember[T](compute: () -> T) -> T: ... +``` + +```by +from basedpython_ui import State, StateList, state, state_list, derived, remember + +frozen data class Todo: + title: str + +data class Draft: + title: str + +def slots(): + let count = state(0) + let names = state(("a", "b")) + let todo = state(Todo("a")) + # error: [mutable-state-value] "`list[int]` cannot be held in state: a change to it cannot be observed; use `state_list`, a `tuple`, or a `frozen data class`" + let items = state([1, 2]) + # error: [mutable-state-value] "`Draft` cannot be held in state: a change to it cannot be observed; use `state_list`, a `tuple`, or a `frozen data class`" + let draft = state(Draft("a")) + # error: [mutable-state-value] "`list[int]` cannot be held in state" + let named = State([1]) + let todos = state_list([Todo("a")]) + # error: [mutable-state-value] "`list[int]` cannot be held in state" + let nested = state_list([[1]]) + # error: [mutable-state-value] "`set[int]` cannot be held in state" + let listed = StateList([{1}]) + let total = derived(lambda: count.value + 1) + # error: [mutable-state-value] "`list[int]` cannot be held in state" + let doubled = derived(lambda: [count.value]) + let cached = remember(lambda: (1, 2)) + # error: [mutable-state-value] "`dict[str, int]` cannot be held in state" + let bag = remember(lambda: {"a": 1}) +``` + +### a value written into an observable afterwards + +```toml +[environment] +python = "/.venv" +``` + +The write is checked the same way, whatever the observable's declared type admits. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export State, StateList, StateDict, Ambient, provide +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +class State[T]: + value: T + def set(self, new: T) -> None: ... + +class StateList[T]: + def __setitem__(self, index: int, value: T) -> None: ... + def append(self, value: T) -> None: ... + def insert(self, index: int, value: T) -> None: ... + +class StateDict[K, V]: + def __setitem__(self, key: K, value: V) -> None: ... + +class Ambient[T]: + current: T + +def provide[T](which: Ambient[T], value: T, once content: () -> None) -> None: ... +``` + +```by +from basedpython_ui import State, StateList, StateDict, Ambient, provide + +frozen data class Todo: + title: str + +def writes(cell: State[object], todos: StateList[object], table: StateDict[str, object], scale: Ambient[object]): + cell.value = 1 + # error: [mutable-state-value] "`list[int]` cannot be held in state" + cell.value = [1] + # error: [mutable-state-value] "`list[int]` cannot be held in state" + cell.set([1]) + todos.append(Todo("b")) + # error: [mutable-state-value] "`list[int]` cannot be held in state" + todos.append([1]) + # error: [mutable-state-value] "`list[int]` cannot be held in state" + todos.insert(0, [1]) + # error: [mutable-state-value] "`list[int]` cannot be held in state" + todos[0] = [1] + # error: [mutable-state-value] "`list[int]` cannot be held in state" + table["a"] = [1] + provide(scale, 2.0): + pass + # error: [mutable-state-value] "`list[int]` cannot be held in state" + provide(scale, [1]): + pass +``` + +### a generic helper is not blamed for its type variable + +```toml +[environment] +python = "/.venv" +``` + +Inside the helper the held type is a type variable, which stands for whatever the caller passes; +that is checked where the call is solved, not in the helper. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export State +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +class State[T]: + value: T + def __init__(self, initial: T) -> None: ... +``` + +```by +from basedpython_ui import State + +def hold[T](value: T) -> State[T]: + return State(value) + +let held = hold([1]) +``` + +## `silent-mutation`: an in-place mutation a composition cannot observe + +A composition re-runs when an observable it read is written. A `list` or a plain object is not +observable: mutating it in place changes what the ui should show without telling the runtime. The +check reports a mutating call, an in-place operator, a subscript store or delete on a builtin +mutable container, and an attribute store on an instance of a class that is not frozen — anywhere in +a composable: its body, a content block, a handler block, a lambda or a nested `def`. + +### in the composable's body + +```toml +[environment] +python = "/.venv" +``` + +The composable is named in the message, and its header carries a secondary annotation. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +def composable[F](fn: F) -> F: ... +``` + +```by +from basedpython_ui import composable + +data class Draft: + title: str + +def load() -> list[str]: + return [] + +def load_draft() -> Draft: + return Draft("") + +@composable +def TodoList(): + var items = load() + items.append("x") # snapshot: silent-mutation + # error: [silent-mutation] "`items[...] = ...` mutates `list[str]` in place" + items[0] = "y" + # error: [silent-mutation] "`del items[...]` mutates `list[str]` in place" + del items[0] + # error: [silent-mutation] "`items += ...` mutates `list[str]` in place" + items += ["z"] + # error: [silent-mutation] "`items.sort(...)` mutates `list[str]` in place" + items.sort() + let draft = load_draft() + # error: [silent-mutation] "`draft.title = ...` mutates `Draft` in place, which `TodoList`'s composition cannot observe; mutate a `StateList` or rebuild an immutable value" + draft.title = "x" +``` + +```snapshot +error[silent-mutation]: `items.append(...)` mutates `list[str]` in place, which `TodoList`'s composition cannot observe; mutate a `StateList` or rebuild an immutable value + --> src/mdtest_snippet.by:15:5 + | +13 | def TodoList(): + | ---------- `TodoList` composes here +14 | var items = load() +15 | items.append("x") # snapshot: silent-mutation + | ^^^^^^^^^^^^^^^^^ +``` + +### in a handler block, a lambda and a nested `def` + +```toml +[environment] +python = "/.venv" +``` + +They mutate the value the composition showed, so they are checked too. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable +from .widgets export Button, Column +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Button(label: str, on_click: () -> None) -> None: ... +@builder +def Column(once content: () -> None) -> None: ... +``` + +```by +from basedpython_ui import composable, Button, Column + +def load() -> list[str]: + return [] + +@composable +def Handlers(): + let items = load() + Button("add"): + # error: [silent-mutation] "`items.append(...)` mutates `list[str]` in place, which `Handlers`'s composition cannot observe" + items.append("x") + Column: + # error: [silent-mutation] "`items.clear(...)` mutates `list[str]` in place" + items.clear() + # error: [silent-mutation] "`items.pop(...)` mutates `list[str]` in place" + Button("drop", on_click=lambda: items.pop()) + + def later(): + # error: [silent-mutation] "`items.reverse(...)` mutates `list[str]` in place" + items.reverse() +``` + +### a fresh local and an observable may be mutated + +```toml +[environment] +python = "/.venv" +``` + +A container or instance the composition creates itself — bound to a display, a comprehension or a +constructor call — is a fresh local that nothing else holds. A `StateList`'s writes notify. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable, state_list +from .widgets export Button, Column +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +from collections.abc import Iterable + +class StateList[T]: + def append(self, value: T) -> None: ... + +def state_list[T](initial: Iterable[T] = ()) -> StateList[T]: ... +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Button(label: str, on_click: () -> None) -> None: ... +@builder +def Column(once content: () -> None) -> None: ... +``` + +```by +from basedpython_ui import composable, state_list, Button, Column + +data class Draft: + title: str + +@composable +def FreshLocals(): + let table: dict[str, int] = {} + table["a"] = 1 + let squares = [n * n for n in range(3)] + squares.append(9) + let made = Draft("a") + made.title = "b" + let todos = state_list(["a"]) + Button("add"): + todos.append("b") + Column: + table["b"] = 2 +``` + +### a read-only view is already rejected, and a plain function is not a composition + +```toml +[environment] +python = "/.venv" +``` + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +def composable[F](fn: F) -> F: ... +``` + +```by +from basedpython_ui import composable + +data class Draft: + title: str + +@composable +def ReadOnly(items: list[out str]): + # error: [invalid-argument-type] + # error: [unobservable-dependency] + items.append("x") + +def helper(items: list[str], draft: Draft): + items.append("x") + draft.title = "y" +``` + +## `state-write-in-composition`: state is written from handlers and effects + +Composition is a pure description of the ui for the current state; a write made while composing +invalidates the frame being built, and the runtime raises before applying it. An assignment to a +`State`'s `.value`, `State.set` / `State.update`, and every mutator of a `StateList` / `StateDict` +are reported in a composable's body and in the content blocks written in it. The observable is named +as it was written. + +### in the composable's body + +```toml +[environment] +python = "/.venv" +``` + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export StateDict, composable, state, state_list, state_dict +from .widgets export Text +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +from collections.abc import Iterable + +class State[T]: + value: T + def set(self, new: T) -> None: ... + def update(self, fn: (T) -> T) -> None: ... + +class StateList[T]: + def __setitem__(self, index: int, value: T) -> None: ... + def append(self, value: T) -> None: ... + def clear(self) -> None: ... + +class StateDict[K, V]: + def __setitem__(self, key: K, value: V) -> None: ... + def remove(self, key: K) -> None: ... + +def state[T](initial: T) -> State[T]: ... +def state_list[T](initial: Iterable[T] = ()) -> StateList[T]: ... +def state_dict[K, V]() -> StateDict[K, V]: ... +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Text(text: str) -> None: ... +``` + +```by +from basedpython_ui import StateDict, composable, state, state_list, state_dict, Text + +@composable +def Counter(): + let count = state(0) + let todos = state_list([1]) + let table: StateDict[str, int] = state_dict() + # error: [state-write-in-composition] "`count` is written while `Counter` is composing; move the write into an event handler or an effect" + count.value = 1 + # error: [state-write-in-composition] "`count` is written while `Counter` is composing" + count.value += 1 + # error: [state-write-in-composition] "`count` is written while `Counter` is composing" + count.set(2) + # error: [state-write-in-composition] "`count` is written while `Counter` is composing" + count.update(lambda c: c + 1) + # error: [state-write-in-composition] "`todos` is written while `Counter` is composing" + todos.append(2) + # error: [state-write-in-composition] "`todos` is written while `Counter` is composing" + todos[0] = 3 + # error: [state-write-in-composition] "`todos` is written while `Counter` is composing" + todos.clear() + # error: [state-write-in-composition] "`table` is written while `Counter` is composing" + table["a"] = 1 + # error: [state-write-in-composition] "`table` is written while `Counter` is composing" + table.remove("a") + Text(f"{count.value}") +``` + +### which scopes run while composing + +```toml +[environment] +python = "/.venv" +``` + +A content block runs while composing, and so does the `local` block of a keyed `each`; a handler +block, a lambda, a nested `def` and an effect block run later, which is where writes belong. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable, state, state_list, launched_effect +from .widgets export Button, Column +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +from collections.abc import Iterable + +class Job: ... + +class State[T]: + value: T + def set(self, new: T) -> None: ... + +class StateList[T]: + def each(self, key: (T) -> object, local content: (T) -> None) -> None: ... + +def state[T](initial: T) -> State[T]: ... +def state_list[T](initial: Iterable[T] = ()) -> StateList[T]: ... +def launched_effect(key: object, block: (Job) -> None) -> None: ... +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Button(label: str, on_click: () -> None) -> None: ... +@builder +def Column(once content: () -> None) -> None: ... +``` + +```by +from basedpython_ui import composable, state, state_list, launched_effect, Button, Column + +@composable +def Scopes(): + let count = state(0) + let todos = state_list([1]) + Column: + # error: [state-write-in-composition] "`count` is written while `Scopes` is composing" + count.value = 3 + todos.each(key=lambda n: n): + # error: [state-write-in-composition] "`count` is written while `Scopes` is composing" + count.value = it + Button("+"): + count.value += 1 + Button("reset", on_click=lambda: count.set(0)) + launched_effect(count.value): + count.value = 5 + + def later(): + count.value = 0 +``` + +## `conditional-slot`: a slot is created and disposed with its condition + +A slot — `state`, `state_list`, `state_dict`, `derived`, `remember` and the effects — lives as long +as its composition scope and is identified by its call site. Created under a condition, it is +created when the condition first holds and disposed as soon as it stops holding: its state is lost +and its effect cancelled. The runtime handles that correctly; the warning makes the lifetime +visible. + +### every conditional construct counts + +```toml +[environment] +python = "/.venv" +``` + +So do a comprehension and a conditional expression. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable, state, derived, remember, launched_effect, disposable_effect, side_effect +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +class Job: ... +class DisposeScope: ... + +class State[T]: + value: T + +class Derived[T]: + value: T + +def state[T](initial: T) -> State[T]: ... +def derived[T](compute: () -> T) -> Derived[T]: ... +def remember[T](compute: () -> T) -> T: ... +def launched_effect(key: object, block: (Job) -> None) -> None: ... +def disposable_effect(key: object, block: (DisposeScope) -> None) -> None: ... +def side_effect(block: () -> None) -> None: ... +def composable[F](fn: F) -> F: ... +``` + +```by +from basedpython_ui import composable, state, derived, remember, launched_effect, disposable_effect, side_effect + +@composable +def Profile(show: bool, ids: tuple[int, ...]): + let clicks = state(0) + if show: + # error: [conditional-slot] "`state()` under a condition: it will be created and disposed as the condition changes" + let extra = state(0) + for id in ids: + # error: [conditional-slot] "`derived()` under a condition: it will be created and disposed as the condition changes" + per = derived(lambda: id) + while show: + # error: [conditional-slot] "`remember()` under a condition" + remember(lambda: 1) + break + try: + # error: [conditional-slot] "`side_effect()` under a condition" + side_effect: + pass + except ValueError: + pass + match show: + case True: + # error: [conditional-slot] "`launched_effect()` under a condition" + launched_effect(1): + pass + case _: + pass + # error: [conditional-slot] "`state()` under a condition" + let listed = [state(i) for i in ids] + # error: [conditional-slot] "`state()` under a condition" + let picked = state(1) if show else None + disposable_effect(1): + pass +``` + +### a `finally` body is not a condition + +Every other part of a `try` depends on how the body exited, so a slot in one lives as long as that +outcome. A `finally` body runs whatever happened above it, so a slot written there is created +exactly as often as the statement is reached. + +```toml +[environment] +python = "/.venv" +``` + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable, state +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +class State[T]: + value: T + +def state[T](initial: T) -> State[T]: ... +def composable[F](fn: F) -> F: ... +``` + +```by +from basedpython_ui import composable, state + +def risky() -> None: ... + +@composable +def Cleanup(): + try: + risky() + # error: [conditional-slot] "`state()` under a condition" + let started = state(0) + except Exception: + # error: [conditional-slot] "`state()` under a condition" + let failed = state(0) + else: + # error: [conditional-slot] "`state()` under a condition" + let succeeded = state(0) + finally: + let always = state(0) +``` + +### a content block runs with its composable; a handler block does not + +```toml +[environment] +python = "/.venv" +``` + +A slot in a content block is fine — unless the block itself sits under a condition. A block that is +not `once` (a handler) may run any number of times, and a slot created there has no scope to live +in. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable, state +from .widgets export Button, Column +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +class State[T]: + value: T + +def state[T](initial: T) -> State[T]: ... +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Button(label: str, on_click: () -> None) -> None: ... +@builder +def Column(once content: () -> None) -> None: ... +``` + +```by +from basedpython_ui import composable, state, Button, Column + +@composable +def Blocks(show: bool): + Column: + let inner = state(0) + if show: + # error: [conditional-slot] "`state()` under a condition" + let cond = state(0) + if show: + Column: + # error: [conditional-slot] "`state()` under a condition" + let nested = state(0) + Button("x"): + # error: [conditional-slot] "`state()` under a condition" + let handler_state = state(0) +``` + +## `content-block-control-flow`: a `return` in a nested content block goes nowhere + +A `once` block's `return` leaves the scope the block is written in — but only that one. When that +scope is itself a block, the `return` leaves the inner block and stops: the enclosing function keeps +running and the value is discarded. This is a property of the language, so it needs no framework: +any `once` callee will do. (A `break` or `continue` in a block is already rejected as `break` +outside loop.) + +```by +def Column(once content: () -> None): + content() + +def Row(once content: () -> None): + content() + +def outer() -> int: + Column: + Row: + # error: [content-block-control-flow] "`return` inside a nested content block leaves only the block; it cannot leave `outer`" + return 1 + return 2 + return 0 +``` + +## `unstable-parameter`: an unstable argument is never skipped + +A composable is skipped on recomposition only when every argument is stable and equal to the last +one. A parameter whose declared type is not deeply immutable — and not a read-only view of immutable +elements — disables skipping for the whole scope. This is a warning about skipping alone: whether +the composition may *read* such a parameter is the question `unobservable-dependency` asks, so the +suggested spellings never include a read-only view. + +### the message suggests the stable spellings for the shape at hand + +```toml +[environment] +python = "/.venv" +``` + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +def composable[F](fn: F) -> F: ... +``` + +```by +from basedpython_ui import composable + +data class Draft: + title: str + +@composable +def TodoList( + # error: [unstable-parameter] "`items: list[int]` is unstable, so `TodoList` is never skipped; prefer `tuple[int, ...]`, `state_list`, or a `frozen data class`" + items: list[int], + # error: [unstable-parameter] "`tags: set[str]` is unstable, so `TodoList` is never skipped; prefer `frozenset[str]` or `state_list`" + tags: set[str], + # error: [unstable-parameter] "`table: dict[str, int]` is unstable, so `TodoList` is never skipped; prefer `state_dict` or a `frozen data class`" + table: dict[str, int], + # error: [unstable-parameter] "`draft: Draft` is unstable, so `TodoList` is never skipped; prefer a `frozen data class` or an observable" + draft: Draft, + # error: [unstable-parameter] "`theme: Draft` is unstable, so `TodoList` is never skipped; prefer a `frozen data class` or an observable" + context theme: Draft, + once content: () -> None, +): + content() +``` + +### a use-site modifier does not change what a parameter is + +`final list[out int]` is the read-only view inside it, and `final list[str]` is a plain list — a +restriction written at the use site says nothing about stability, and the message names the shape it +finds underneath. + +```toml +[environment] +python = "/.venv" +``` + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +def composable[F](fn: F) -> F: ... +``` + +```by +from basedpython_ui import composable + +@composable +def Viewed(items: final list[out int]): ... + +@composable +# error: [unstable-parameter] "`items: final list[str]` is unstable, so `Held` is never skipped; prefer `tuple[str, ...]`, `state_list`, or a `frozen data class`" +def Held(items: final list[str]): ... +``` + +### immutable values, observables, callables and read-only views are stable + +```toml +[environment] +python = "/.venv" +``` + +So is an unannotated parameter, about which nothing is known; and a plain function's parameters are +not looked at. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export StateList, composable +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +class StateList[T]: ... + +def composable[F](fn: F) -> F: ... +``` + +```by +from basedpython_ui import StateList, composable + +frozen data class Todo: + title: str + +@composable +def Skippable( + count: int, + name: str | None, + todo: Todo, + ids: tuple[int, ...], + todos: StateList[Todo], + view: list[out int], + on_click: () -> None, + anything, + once content: () -> None, +): + content() + +def helper(items: list[int]): ... +``` + +## `composable-outside-composition`: a composable is called while composing + +A composable opens a scope in the composition being built and a builder emits into it; neither has +anything to build into outside of one. + +### an ordinary helper of the widgets module is not a builder + +The `basedpython_ui.widgets` module is free to hold functions that emit nothing — a helper that +computes a default, say. Being declared there is not what makes a function a builder; the +framework's `@builder` decorator is, exactly as `@composable` is what makes a composition scope. An +undecorated helper stays callable from anywhere. + +```toml +[environment] +python = "/.venv" +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Text(text: str) -> None: ... + +def default_padding() -> int: ... +``` + +```by +from basedpython_ui.widgets import Text, default_padding + +let pad = default_padding() + +def measure() -> int: + return default_padding() + +# error: [composable-outside-composition] "`Text` is a builder and can only be called while composing" +Text("x") +``` + +### a plain function and the module are not compositions + +```toml +[environment] +python = "/.venv" +``` + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable +from .widgets export Text +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Text(text: str) -> None: ... +``` + +```by +from basedpython_ui import composable, Text + +@composable +def Counter(): ... + +@composable +def Card(once content: () -> None): + content() + +def helper(): + # error: [composable-outside-composition] "`Counter` is a composable and can only be called while composing" + Counter() + # error: [composable-outside-composition] "`Text` is a builder and can only be called while composing" + Text("x") + # error: [composable-outside-composition] "`Card` is a composable and can only be called while composing" + Card: + pass + +# error: [composable-outside-composition] "`Counter` is a composable and can only be called while composing" +Counter() +``` + +### inside a composition the calls are what composition is made of + +```toml +[environment] +python = "/.venv" +``` + +A composable's body, its content blocks and a keyed `each` are compositions. A handler block, a +lambda and a nested `def` run after composition, so a call from one of those is reported. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable, keyed +from .widgets export Text, Button, Column +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +from collections.abc import Iterable + +class Keyed[T]: + def each(self, key: (T) -> object, local content: (T) -> None) -> None: ... + +def keyed[T](items: Iterable[T]) -> Keyed[T]: ... +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Text(text: str) -> None: ... +@builder +def Button(label: str, on_click: () -> None) -> None: ... +@builder +def Column(once content: () -> None) -> None: ... +``` + +```by +from basedpython_ui import composable, keyed, Text, Button, Column + +@composable +def Counter(): ... + +@composable +def Card(once content: () -> None): + content() + +@composable +def App(): + Counter() + Text("x") + Column: + Counter() + Card: + Counter() + keyed(("a", "b")).each(key=lambda s: s): + Text(it) + Button("x"): + # error: [composable-outside-composition] "`Counter` is a composable and can only be called while composing" + Counter() + # error: [composable-outside-composition] "`Counter` is a composable and can only be called while composing" + Button("y", on_click=lambda: Counter()) + + def later(): + # error: [composable-outside-composition] "`Text` is a builder and can only be called while composing" + Text("z") +``` + +### the root of an app, a test or a runtime is where a composition starts + +```toml +[environment] +python = "/.venv" +``` + +The `root` block of `run_app` / `compose_test` is a composition of its own. So is whatever is handed +to the runtime's own `Runtime.set_root` — a lambda or a function — which the two wrap and which a +test or a benchmark drives directly. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export Runtime, composable +from .widgets export Text +from .app export run_app, compose_test +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +class Runtime: + def set_root(self, root: () -> None) -> None: ... + +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Text(text: str) -> None: ... +``` + +`/.venv//basedpython_ui/app.byi`: + +```byi +class TestComposition: ... + +def run_app(title: str, root: () -> None) -> None: ... +def compose_test(root: () -> None) -> TestComposition: ... +``` + +```by +from basedpython_ui import Runtime, composable, Text, run_app, compose_test + +@composable +def Counter(step: int = 1): ... + +def main(): + run_app("app"): + Counter() + let t = compose_test: + Counter(step=2) + +def bench(rt: Runtime): + rt.set_root(lambda: Counter(2)) + Runtime().set_root(lambda: Text("x")) + + def root(): + Counter() + + rt.set_root(root) + rt.set_root(root=lambda: Counter(3)) + +def elsewhere(rt: Runtime): + # error: [composable-outside-composition] "`Counter` is a composable and can only be called while composing" + let make = lambda: Counter() + rt.set_root(make) +``` + +## `unobservable-dependency`: a composition reads only what it can observe + +A mutation of non-observable data is never a trigger: an immutable value cannot change, an +observable notifies its readers when it does, and a `list`, a `dict` or a plain object changes +without telling anyone. So a composition may only depend on immutable or observable values — then it +does not matter where a write happens. The check reports a load, while composing, of a name the +composition did not bind itself: a parameter of the composable, a module global, or a local captured +from an enclosing function, whose type is neither deeply immutable nor an observable. What runs +while composing is the composable's body, the `once` content blocks and `local` blocks written in +it, and the lambda given to `derived` / `remember`; a handler block, any other lambda, a nested +`def` or an effect block runs later, and a read there is not a dependency of the composition. + +### a parameter read in the body + +```toml +[environment] +python = "/.venv" +``` + +The message names the parameter with its type, and the composable's header carries a secondary +annotation. A read in a content block written in the body is a read of the composition too. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable +from .widgets export Text, Column +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Text(text: str) -> None: ... +@builder +def Column(once content: () -> None) -> None: ... +``` + +```by +from basedpython_ui import composable, Text, Column + +@composable +def Names( + # error: [unstable-parameter] + items: list[str], +): + Text(str(len(items))) # snapshot: unobservable-dependency + Column: + # error: [unobservable-dependency] "`items: list[str]` is read while `Names` composes" + Text(items[0]) +``` + +```snapshot +error[unobservable-dependency]: `items: list[str]` is read while `Names` composes, but nothing observes a change to it; hold it in state (`state_list`), pass an immutable value (`tuple[str, ...]`, a `frozen data class`), or read it only in a handler + --> src/mdtest_snippet.by:8:18 + | +4 | def Names( + | _____- +5 | | # error: [unstable-parameter] +6 | | items: list[str], +7 | | ): + | |_- `Names` composes here +8 | Text(str(len(items))) # snapshot: unobservable-dependency + | ^^^^^ +``` + +### the message suggests the observable spellings for the shape at hand + +```toml +[environment] +python = "/.venv" +``` + +The state constructor that holds a value of the parameter's shape, and an immutable spelling of it; +a plain object has neither, and should be a frozen record or an observable instead. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable +from .widgets export Text +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Text(text: str) -> None: ... +``` + +```by +from basedpython_ui import composable, Text + +data class Draft: + title: str + +@composable +def Shapes( + # error: [unstable-parameter] + tags: set[str], + # error: [unstable-parameter] + table: dict[str, int], + # error: [unstable-parameter] + draft: Draft, +): + # error: [unobservable-dependency] "`tags: set[str]` is read while `Shapes` composes, but nothing observes a change to it; hold it in state (`state_list`), pass an immutable value (`frozenset[str]`), or read it only in a handler" + Text(str(len(tags))) + # error: [unobservable-dependency] "`table: dict[str, int]` is read while `Shapes` composes, but nothing observes a change to it; hold it in state (`state_dict`), pass an immutable value (a `frozen data class`), or read it only in a handler" + Text(str(len(table))) + # error: [unobservable-dependency] "`draft: Draft` is read while `Shapes` composes, but nothing observes a change to it; pass a `frozen data class` or an observable, or read it only in a handler" + Text(draft.title) +``` + +### a parameter read only in a handler is not a dependency + +```toml +[environment] +python = "/.venv" +``` + +The handler runs after composition, so the composition never depended on the value. What remains is +the `unstable-parameter` warning: the composable is never skipped. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable +from .widgets export Button +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Button(label: str, on_click: () -> None) -> None: ... +``` + +```by +from basedpython_ui import composable, Button + +@composable +def Handler( + # error: [unstable-parameter] "`items: list[str]` is unstable, so `Handler` is never skipped; prefer `tuple[str, ...]`, `state_list`, or a `frozen data class`" + items: list[str], +): + Button("count"): + print(len(items)) + Button("last", on_click=lambda: print(items[-1])) +``` + +### a python file is told the spelling its own syntax has + +The checks apply to a `.py` file that uses the framework, so the suggestions have to be spellings a +python file can actually write. + +```toml +[environment] +python = "/.venv" +``` + +`/.venv//basedpython_ui/__init__.pyi`: + +```pyi +from .runtime import composable as composable +``` + +`/.venv//basedpython_ui/runtime.pyi`: + +```pyi +def composable[F](fn: F) -> F: ... +``` + +```py +from basedpython_ui import composable + +@composable +# error: [unstable-parameter] "prefer `tuple[str, ...]`, `state_list`, or a `@dataclass(frozen=True)`" +def Names(items: list[str]): + # error: [unobservable-dependency] "hold it in state (`state_list`), pass an immutable value (`tuple[str, ...]`, a `@dataclass(frozen=True)`), or read it only in a handler" + print(len(items)) +``` + +### a mutable global read in the body + +```toml +[environment] +python = "/.venv" +``` + +A global is named with its type; an immutable global is fine. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable +from .widgets export Text +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Text(text: str) -> None: ... +``` + +```by +from basedpython_ui import composable, Text + +frozen data class Todo: + title: str + +let TODOS: list[Todo] = [] +let TITLES: tuple[str, ...] = ("a", "b") + +@composable +def Names(): + # error: [unobservable-dependency] "`TODOS` (`list[Todo]`) is read while `Names` composes, but nothing observes a change to it; hold it in state (`state_list`), make it immutable, or read it only in a handler" + Text(str(len(TODOS))) + Text(TITLES[0]) +``` + +### a local captured from an enclosing function + +```toml +[environment] +python = "/.venv" +``` + +A composable defined inside a function reads that function's locals as a closure; they are as +invisible to it as a global. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable +from .widgets export Text +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Text(text: str) -> None: ... +``` + +```by +from basedpython_ui import composable, Text + +def make(): + let items: list[str] = [] + let names: tuple[str, ...] = ("a",) + + @composable + def Names(): + # error: [unobservable-dependency] "`items` (`list[str]`) is read while `Names` composes, but nothing observes a change to it; hold it in state (`state_list`), make it immutable, or read it only in a handler" + Text(str(len(items))) + Text(names[0]) +``` + +### a read-only view restricts only this reader + +```toml +[environment] +python = "/.venv" +``` + +A `list[out str]` parameter cannot be written through, but the list behind it can be written by +whoever else holds it — so it is reported like a plain `list`. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable +from .widgets export Text +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Text(text: str) -> None: ... +``` + +```by +from basedpython_ui import composable, Text + +@composable +def View(items: list[out str]): + # error: [unobservable-dependency] "`items: list[out str]` is read while `View` composes, but nothing observes a change to it; hold it in state (`state_list`), pass an immutable value (`tuple[str, ...]`, a `frozen data class`), or read it only in a handler" + Text(str(len(items))) +``` + +### immutable values and observables are what a composition may depend on + +```toml +[environment] +python = "/.venv" +``` + +A frozen record, a tuple, a `StateList`, a `State`, an `Ambient`'s `current`, a frozen `context` +parameter and a callable are all fine to read while composing. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export State, StateList, Ambient, composable, ambient +from .widgets export Text, Button +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +class State[T]: + value: T + +class StateList[T]: + def __len__(self) -> int: ... + +class Ambient[T]: + current: T + +def ambient[T](default: T) -> Ambient[T]: ... +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Text(text: str) -> None: ... +@builder +def Button(label: str, on_click: () -> None) -> None: ... +``` + +```by +from basedpython_ui import State, StateList, Ambient, composable, ambient, Text, Button + +frozen data class Theme: + name: str + +let density = ambient(1.0) + +@composable +def Seen( + todo: Theme, + ids: tuple[int, ...], + todos: StateList[str], + count: State[int], + on_click: () -> None, + context theme: Theme, +): + Text(todo.name) + Text(str(ids[0])) + Text(str(len(todos))) + Text(str(count.value)) + Text(str(density.current)) + Text(theme.name) + Button("x", on_click=on_click) +``` + +### a local the composition creates is its own value + +```toml +[environment] +python = "/.venv" +``` + +A name bound in the body — whatever its origin — is this run's value, and so is one bound in a +content block written in the body, a `for` target or a comprehension variable. Reading it is not +depending on anything outside the composition. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable +from .widgets export Text, Column +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Text(text: str) -> None: ... +@builder +def Column(once content: () -> None) -> None: ... +``` + +```by +from basedpython_ui import composable, Text, Column + +def load() -> list[str]: + return [] + +@composable +def Own(): + let items = load() + let table: dict[str, int] = {} + Text(str(len(items))) + Column: + Text(str(len(table))) + let inner = load() + Text(str(len(inner))) + for item in items: + Text(item) + Text(str([len(name) for name in items])) +``` + +### the lambda given to `derived` / `remember` reads for the composition + +```toml +[environment] +python = "/.venv" +``` + +What the computation reads is what it depends on, so a mutable parameter read there is reported; any +other lambda runs later. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable, state, derived, remember +from .widgets export Text, Button +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +class State[T]: + value: T + +class Derived[T]: + value: T + +def state[T](initial: T) -> State[T]: ... +def derived[T](compute: () -> T) -> Derived[T]: ... +def remember[T](compute: () -> T) -> T: ... +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Text(text: str) -> None: ... +@builder +def Button(label: str, on_click: () -> None) -> None: ... +``` + +```by +from basedpython_ui import composable, state, derived, remember, Text, Button + +@composable +def Computed( + # error: [unstable-parameter] + items: list[str], +): + let count = state(0) + # error: [unobservable-dependency] "`items: list[str]` is read while `Computed` composes" + let total = derived(lambda: len(items) + count.value) + # error: [unobservable-dependency] "`items: list[str]` is read while `Computed` composes" + let first = remember(lambda: items[0]) + Text(str(total.value)) + Text(first) + Button("log", on_click=lambda: print(len(items))) +``` + +### a helper in another module cannot make the composition stale + +```toml +[environment] +python = "/.venv" +``` + +The write-side check cannot see a mutation made by a callee in another module. It does not need to: +the composition is reported where it reads the list, and the helper — no composition at all — is +not. + +`/.venv//basedpython_ui/__init__.byi`: + +```byi +from .runtime export composable +from .widgets export Text, Button +``` + +`/.venv//basedpython_ui/runtime.byi`: + +```byi +def composable[F](fn: F) -> F: ... +def builder[F](fn: F) -> F: ... +``` + +`/.venv//basedpython_ui/widgets.byi`: + +```byi +from .runtime import builder + +@builder +def Text(text: str) -> None: ... +@builder +def Button(label: str, on_click: () -> None) -> None: ... +``` + +`helpers.by`: + +```by +def add_item(items: list[str], item: str): + items.append(item) +``` + +```by +from basedpython_ui import composable, Text, Button +from helpers import add_item + +@composable +def Names( + # error: [unstable-parameter] + items: list[str], +): + # error: [unobservable-dependency] "`items: list[str]` is read while `Names` composes, but nothing observes a change to it; hold it in state (`state_list`), pass an immutable value (`tuple[str, ...]`, a `frozen data class`), or read it only in a handler" + Text(str(len(items))) + Button("add"): + add_item(items, "x") +``` diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_ui_loop_capture_through_once.md b/crates/ty_python_semantic/resources/mdtest/basedpython_ui_loop_capture_through_once.md new file mode 100644 index 0000000000..7dc498cf5e --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_ui_loop_capture_through_once.md @@ -0,0 +1,87 @@ +# basedpython-ui: `escaping-loop-variable` sees through `once` blocks + +A `once` block runs inline, exactly once, so it never lets a captured loop variable dangle — and it +is not where the check stops. A handler block nested inside it whose callee is not a borrow may be +kept and run after the loop advances, when the variable holds its final value: the classic late +binding trap, one level down. A compose-style tree nests exactly like this — a keyed `once` scope +around a widget whose click handler reads the loop item. + +```toml +[environment] +python-version = "3.12" +``` + +## a handler block inside a `once` block inside a loop is flagged + +```by +def key(k: object, once content: () -> None): + content() + +def Button(label: str, on_click: () -> None): ... + +for item in ("a", "b"): + key(item): + Button(item): + print(item) # error: [escaping-loop-variable] +``` + +## the nesting may be deeper + +```by +def key(k: object, once content: () -> None): + content() + +def group(once content: () -> None): + content() + +def Button(label: str, on_click: () -> None): ... + +for item in ("a", "b"): + key(item): + group: + Button(item): + print(item) # error: [escaping-loop-variable] +``` + +## a `once` handler nested in a `once` block is confined + +```by +def key(k: object, once content: () -> None): + content() + +def run(once fn: () -> None): + fn() + +for item in ("a", "b"): + key(item): + run: + print(item) +``` + +## a nested handler that does not capture the loop variable is fine + +```by +def key(k: object, once content: () -> None): + content() + +def Button(label: str, on_click: () -> None): ... + +for item in ("a", "b"): + key(item): + Button("static"): + print("clicked") +``` + +## a nested handler outside any loop is fine + +```by +def key(k: object, once content: () -> None): + content() + +def Button(label: str, on_click: () -> None): ... + +item = "a" +key(item): + Button(item): + print(item) +``` diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_ui_recognition.md b/crates/ty_python_semantic/resources/mdtest/basedpython_ui_recognition.md new file mode 100644 index 0000000000..20043af98e --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_ui_recognition.md @@ -0,0 +1,117 @@ +# basedpython-ui: recognising the framework + +`basedpython_ui` is a compose-style ui framework. Its observables — `State[T]`, `StateList[T]`, +`StateDict[K, V]`, `Derived[T]` and `Ambient[T]`, declared in `basedpython_ui.runtime` — and its +`@composable` decorator are recognised by their resolved definitions, so the ui-specific checks can +build on them. The package re-exports everything from its `__init__`, and recognition follows the +re-export. Each section below installs a mock of the package in site-packages. + +## the observables are ordinary generic classes + +An observable's value keeps the type it was created with, whether the class is named directly or +through the constructor function. + +```toml +[environment] +python = "/.venv" +``` + +`/.venv//basedpython_ui/__init__.pyi`: + +```pyi +from .runtime import ( + Ambient as Ambient, + Derived as Derived, + State as State, + StateDict as StateDict, + StateList as StateList, + state as state, +) +``` + +`/.venv//basedpython_ui/runtime.pyi`: + +```pyi +from collections.abc import Iterator + +class State[T]: + value: T + def __init__(self, initial: T) -> None: ... + +class StateList[T]: + def __iter__(self) -> Iterator[T]: ... + +class StateDict[K, V]: + def __getitem__(self, key: K) -> V: ... + +class Derived[T]: + value: T + +class Ambient[T]: + default: T + +def state[T](initial: T) -> State[T]: ... +``` + +```by +from basedpython_ui import Ambient, Derived, State, StateDict, StateList, state + +let count = state(0) +reveal_type(count) # revealed: State[int] +reveal_type(count.value) # revealed: int + +let named = State("x") +reveal_type(named.value) # revealed: str + +def show(items: StateList[str], table: StateDict[str, int], total: Derived[float], theme: Ambient[str]): + for item in items: + reveal_type(item) # revealed: str + reveal_type(table["a"]) # revealed: int + reveal_type(total.value) # revealed: float + reveal_type(theme.default) # revealed: str +``` + +## a composable keeps its function type + +`@composable` is identity-typed, so a decorated function is still the function it was written as — a +trailing block, a `once` callback and every other function-literal feature keep working through it. +The decorator is recognised through the package re-export and through its declaring module alike. + +```toml +[environment] +python = "/.venv" +``` + +`/.venv//basedpython_ui/__init__.pyi`: + +```pyi +from .runtime import composable as composable +``` + +`/.venv//basedpython_ui/runtime.pyi`: + +```pyi +def composable[F](fn: F) -> F: ... +``` + +```by +from basedpython_ui import composable +from basedpython_ui.runtime import composable as declared + +@composable +def Counter(label: str, once content: () -> None) -> None: + content() + +reveal_type(Counter) # revealed: def Counter(label: str, content: () -> None) + +@composable +def App(): + Counter("clicks"): + pass + +@declared +def Row() -> None: ... + +reveal_type(Row) # revealed: def Row() + +``` diff --git a/crates/ty_python_semantic/src/semantic_model.rs b/crates/ty_python_semantic/src/semantic_model.rs index a8a9ef15bd..07c8e61d07 100644 --- a/crates/ty_python_semantic/src/semantic_model.rs +++ b/crates/ty_python_semantic/src/semantic_model.rs @@ -982,8 +982,14 @@ impl<'db> SemanticModel<'db> { /// implicit receiver — the block then binds it as a leading parameter, which /// its body reads members off unqualified and spells `self` pub fn trailing_lambda_callback_has_receiver(&self, callee: &ast::Expr) -> bool { + // whether a receiver is declared does not depend on what the call + // solves, so the callee alone answers callee.inferred_type(self).is_some_and(|ty| { - crate::types::trailing_lambda::trailing_lambda_receiver_type(self.db, ty).is_some() + crate::types::trailing_lambda::trailing_lambda_receiver_type( + self.db, + crate::types::trailing_lambda::BlockCallee::unspecialized(ty), + ) + .is_some() }) } diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 61b8c46709..40c4f3bf42 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -177,6 +177,7 @@ mod callable; pub mod character; mod class; mod class_base; +pub(crate) mod composition; pub(crate) mod conformance; mod constraints; pub(crate) mod context; @@ -197,6 +198,7 @@ pub mod format; pub(crate) mod function; mod generics; pub mod ide_support; +pub(crate) mod immutability; pub(crate) mod implicit_names; mod infer; pub(crate) mod inferred_narrowing; @@ -231,6 +233,8 @@ mod set_theoretic; mod signatures; pub mod soundness; mod special_form; +pub(crate) mod state_invalidations; +pub(crate) mod state_reads; pub mod static_resource; mod string_annotation; mod subclass_of; @@ -4318,6 +4322,13 @@ impl<'db> Type<'db> { } } + /// basedpython-ui: whether no value of this type can change after it is + /// created — the framework's notion of a *stable* value, one that may be + /// held in state. See the `immutability` module for exactly what counts. + fn is_deeply_immutable(self, db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> bool { + immutability::is_deeply_immutable(db, env, self) + } + /// Return true if there is just a single inhabitant for this type. /// /// Note: This function aims to have no false positives, but might return `false` diff --git a/crates/ty_python_semantic/src/types/call/arguments.rs b/crates/ty_python_semantic/src/types/call/arguments.rs index 4e12da997c..6ba190abe9 100644 --- a/crates/ty_python_semantic/src/types/call/arguments.rs +++ b/crates/ty_python_semantic/src/types/call/arguments.rs @@ -116,6 +116,15 @@ impl<'db> CallArgumentTypes<'db> { } impl<'a, 'db> CallArguments<'a, 'db> { + /// basedpython: append an argument that has no AST node — the block a + /// trailing lambda passes — with its type already known. + pub(crate) fn push(&mut self, argument: Argument<'a>, ty: Type<'db>) { + self.items.push(CallArgument { + argument, + types: CallArgumentTypes::new(Some(ty)), + }); + } + /// Create `CallArguments` from AST arguments. We will use the provided callback to obtain the /// type of each splatted argument, so that we can determine its length. All other arguments /// will remain uninitialized. diff --git a/crates/ty_python_semantic/src/types/call/bind.rs b/crates/ty_python_semantic/src/types/call/bind.rs index 5da9a8b97b..ce89c6fa5a 100644 --- a/crates/ty_python_semantic/src/types/call/bind.rs +++ b/crates/ty_python_semantic/src/types/call/bind.rs @@ -2621,6 +2621,14 @@ impl<'db> Bindings<'db> { } } + Some(KnownFunction::IsDeeplyImmutable) => { + if let [Some(ty)] = overload.parameter_types() { + overload.set_return_type(Type::bool_literal( + ty.project_type_form(db, env).is_deeply_immutable(db, env), + )); + } + } + Some(KnownFunction::GenericContext) => { if let [Some(ty)] = overload.parameter_types() { let wrap_generic_context = |generic_context| { @@ -10919,13 +10927,17 @@ impl<'db> BindingError<'db> { argument_index: Option, ) -> Option> { match (node, argument_index) { - (ast::AnyNodeRef::ExprCall(call_node), Some(argument_index)) => Some( - call_node - .arguments - .iter_source_order() - .nth(argument_index) - .expect("argument index should not be out of range"), - ), + // basedpython: a call carrying a trailing block binds one argument + // more than it writes — the block, which has no node. An index past + // the written arguments is that block, and reports on the whole call. + // + // Any *other* out-of-range index would be a bug in argument + // matching. This returns `None` for that too rather than panicking: + // the cost is a diagnostic anchored on the call instead of on one + // argument, which is what the block case wants anyway + (ast::AnyNodeRef::ExprCall(call_node), Some(argument_index)) => { + call_node.arguments.iter_source_order().nth(argument_index) + } // If we've been passed a `ClassDef` node, it indicates that we're reporting an error // relating to the class's keyword arguments. Keyword arguments are passed to `__init_subclass__`, // or `__new__`/`__prepare__` on the metaclass -- but positional arguments are not, and neither diff --git a/crates/ty_python_semantic/src/types/class/known.rs b/crates/ty_python_semantic/src/types/class/known.rs index d4ce587fa9..53f6088174 100644 --- a/crates/ty_python_semantic/src/types/class/known.rs +++ b/crates/ty_python_semantic/src/types/class/known.rs @@ -190,6 +190,13 @@ pub enum KnownClass { SqlalchemyMapped, // Pytest PytestParametrizeMarkDecorator, + + // basedpython-ui (`basedpython_ui.runtime`) — the framework's observables + BasedpythonUiState, + BasedpythonUiStateList, + BasedpythonUiStateDict, + BasedpythonUiDerived, + BasedpythonUiAmbient, } impl KnownClass { @@ -329,6 +336,11 @@ impl KnownClass { | Self::SqlalchemyDeclarativeBase | Self::SqlalchemyMappedAsDataclass | Self::SqlalchemyMapped + | Self::BasedpythonUiState + | Self::BasedpythonUiStateList + | Self::BasedpythonUiStateDict + | Self::BasedpythonUiDerived + | Self::BasedpythonUiAmbient | Self::Character => false, } } @@ -481,6 +493,11 @@ impl KnownClass { | Self::SqlalchemyDeclarativeBase | Self::SqlalchemyMappedAsDataclass | Self::SqlalchemyMapped + | Self::BasedpythonUiState + | Self::BasedpythonUiStateList + | Self::BasedpythonUiStateDict + | Self::BasedpythonUiDerived + | Self::BasedpythonUiAmbient | Self::ByStaticProperty | Self::PytestParametrizeMarkDecorator => Some(Truthiness::Ambiguous), @@ -624,6 +641,11 @@ impl KnownClass { | KnownClass::SqlalchemyDeclarativeBase | KnownClass::SqlalchemyMappedAsDataclass | KnownClass::SqlalchemyMapped + | KnownClass::BasedpythonUiState + | KnownClass::BasedpythonUiStateList + | KnownClass::BasedpythonUiStateDict + | KnownClass::BasedpythonUiDerived + | KnownClass::BasedpythonUiAmbient | KnownClass::ByStaticProperty | KnownClass::PytestParametrizeMarkDecorator => false, } @@ -758,6 +780,11 @@ impl KnownClass { | KnownClass::SqlalchemyDeclarativeBase | KnownClass::SqlalchemyMappedAsDataclass | KnownClass::SqlalchemyMapped + | KnownClass::BasedpythonUiState + | KnownClass::BasedpythonUiStateList + | KnownClass::BasedpythonUiStateDict + | KnownClass::BasedpythonUiDerived + | KnownClass::BasedpythonUiAmbient | KnownClass::ByStaticProperty | KnownClass::PytestParametrizeMarkDecorator => false, @@ -894,6 +921,11 @@ impl KnownClass { | KnownClass::SqlalchemyDeclarativeBase | KnownClass::SqlalchemyMappedAsDataclass | KnownClass::SqlalchemyMapped + | KnownClass::BasedpythonUiState + | KnownClass::BasedpythonUiStateList + | KnownClass::BasedpythonUiStateDict + | KnownClass::BasedpythonUiDerived + | KnownClass::BasedpythonUiAmbient | KnownClass::ByStaticProperty | KnownClass::PytestParametrizeMarkDecorator => false, } @@ -1041,6 +1073,11 @@ impl KnownClass { | KnownClass::SqlalchemyDeclarativeBase | KnownClass::SqlalchemyMappedAsDataclass | KnownClass::SqlalchemyMapped + | KnownClass::BasedpythonUiState + | KnownClass::BasedpythonUiStateList + | KnownClass::BasedpythonUiStateDict + | KnownClass::BasedpythonUiDerived + | KnownClass::BasedpythonUiAmbient | KnownClass::ByStaticProperty | Self::PytestParametrizeMarkDecorator => false, } @@ -1177,6 +1214,11 @@ impl KnownClass { | KnownClass::SqlalchemyDeclarativeBase | KnownClass::SqlalchemyMappedAsDataclass | KnownClass::SqlalchemyMapped + | KnownClass::BasedpythonUiState + | KnownClass::BasedpythonUiStateList + | KnownClass::BasedpythonUiStateDict + | KnownClass::BasedpythonUiDerived + | KnownClass::BasedpythonUiAmbient | KnownClass::ByStaticProperty | KnownClass::PytestParametrizeMarkDecorator => false, KnownClass::NamedTupleFallback @@ -1327,6 +1369,11 @@ impl KnownClass { Self::SqlalchemyMappedAsDataclass => "MappedAsDataclass", Self::SqlalchemyMapped => "Mapped", Self::PytestParametrizeMarkDecorator => "_ParametrizeMarkDecorator", + Self::BasedpythonUiState => "State", + Self::BasedpythonUiStateList => "StateList", + Self::BasedpythonUiStateDict => "StateDict", + Self::BasedpythonUiDerived => "Derived", + Self::BasedpythonUiAmbient => "Ambient", } } @@ -1788,6 +1835,11 @@ impl KnownClass { } Self::SqlalchemyMapped => KnownModule::SqlalchemyOrmBase, Self::PytestParametrizeMarkDecorator => KnownModule::PytestMarkStructures, + Self::BasedpythonUiState + | Self::BasedpythonUiStateList + | Self::BasedpythonUiStateDict + | Self::BasedpythonUiDerived + | Self::BasedpythonUiAmbient => KnownModule::BasedpythonUiRuntime, } } @@ -1921,6 +1973,11 @@ impl KnownClass { | KnownClass::SqlalchemyDeclarativeBase | KnownClass::SqlalchemyMappedAsDataclass | KnownClass::SqlalchemyMapped + | KnownClass::BasedpythonUiState + | KnownClass::BasedpythonUiStateList + | KnownClass::BasedpythonUiStateDict + | KnownClass::BasedpythonUiDerived + | KnownClass::BasedpythonUiAmbient | KnownClass::ByStaticProperty | Self::PytestParametrizeMarkDecorator => false, } @@ -2057,6 +2114,11 @@ impl KnownClass { "MappedAsDataclass" => &[Self::SqlalchemyMappedAsDataclass], "Mapped" => &[Self::SqlalchemyMapped], "_ParametrizeMarkDecorator" => &[Self::PytestParametrizeMarkDecorator], + "State" => &[Self::BasedpythonUiState], + "StateList" => &[Self::BasedpythonUiStateList], + "StateDict" => &[Self::BasedpythonUiStateDict], + "Derived" => &[Self::BasedpythonUiDerived], + "Ambient" => &[Self::BasedpythonUiAmbient], _ => return None, }; @@ -2189,6 +2251,11 @@ impl KnownClass { | Self::SqlalchemyDeclarativeBase | Self::SqlalchemyMappedAsDataclass | Self::SqlalchemyMapped + | Self::BasedpythonUiState + | Self::BasedpythonUiStateList + | Self::BasedpythonUiStateDict + | Self::BasedpythonUiDerived + | Self::BasedpythonUiAmbient | Self::ByStaticProperty => module == self.canonical_module(python_version), // no equivalent class exists in typing_extensions, nor ever will diff --git a/crates/ty_python_semantic/src/types/class/slots.rs b/crates/ty_python_semantic/src/types/class/slots.rs index a3f813f2e8..59aeed775b 100644 --- a/crates/ty_python_semantic/src/types/class/slots.rs +++ b/crates/ty_python_semantic/src/types/class/slots.rs @@ -198,6 +198,11 @@ impl InstanceDictionary { | KnownClass::SqlalchemyDeclarativeBase | KnownClass::SqlalchemyMappedAsDataclass | KnownClass::SqlalchemyMapped + | KnownClass::BasedpythonUiState + | KnownClass::BasedpythonUiStateList + | KnownClass::BasedpythonUiStateDict + | KnownClass::BasedpythonUiDerived + | KnownClass::BasedpythonUiAmbient | KnownClass::Character => None, } } diff --git a/crates/ty_python_semantic/src/types/composition.rs b/crates/ty_python_semantic/src/types/composition.rs new file mode 100644 index 0000000000..e4c8b1115b --- /dev/null +++ b/crates/ty_python_semantic/src/types/composition.rs @@ -0,0 +1,2076 @@ +//! basedpython-ui: the composition model's checks +//! +//! A `@composable` function describes a piece of ui as a function of the +//! observables it reads. Its body, the `once` content blocks written in it +//! (`Column:`, `Row:`, …) and the `local` blocks written in it (a keyed +//! `each`) all run *while composing*; a handler block, a lambda, a nested +//! `def` or an effect block written in it runs *later*, in response to an +//! event. That distinction — what runs during composition and what does not — +//! is what every check here is about: +//! +//! - a value that is not deeply immutable cannot be held in state +//! (`mutable-state-value`) +//! - an in-place mutation written anywhere in a composable is invisible to it +//! (`silent-mutation`) +//! - a state write while composing is a write to the frame being built +//! (`state-write-in-composition`) +//! - a slot created under a condition lives as long as the condition +//! (`conditional-slot`) +//! - a `return` in a content block nested in another block goes nowhere +//! (`content-block-control-flow`) +//! - a composable with an unstable parameter is never skipped +//! (`unstable-parameter`) +//! - a composable or builder called outside a composition has nothing to +//! compose into (`composable-outside-composition`) +//! - a composition may only depend on what it can observe: a parameter, a +//! global or a captured name it reads must be deeply immutable or an +//! observable (`unobservable-dependency`) +//! +//! Every scope is checked once, from its own inference: a block is a scope of +//! its own, so what it is part of is found by walking *out* — through `once` +//! blocks that run inline, noting each callback boundary crossed — to the +//! composable whose composition it belongs to ([`composition_of_scope`]). + +use ruff_db::diagnostic::{Annotation, Span}; +use ruff_db::files::File; +use ruff_db::parsed::ParsedModuleRef; +use ruff_db::source::{SourceText, source_text}; +use ruff_python_ast::visitor::{Visitor, walk_expr, walk_stmt}; +use ruff_python_ast::{self as ast, Expr, Stmt}; +use ruff_text_size::{Ranged, TextRange}; +use ty_module_resolver::{KnownModule, resolve_module_confident}; +use ty_python_core::SemanticIndex; +use ty_python_core::scope::{FileScopeId, NodeWithScopeKind}; + +use crate::Db; +use crate::types::context::InferContext; +use crate::types::dedicated::basedpython_ui::{ + ObservableKind, is_composable, is_composition_root, is_set_root, is_slot_function, + is_widget_builder, observable_kind, state_list_element_type, state_value_type, underlying, +}; +use crate::types::diagnostic::{ + COMPOSABLE_OUTSIDE_COMPOSITION, CONDITIONAL_SLOT, CONTENT_BLOCK_CONTROL_FLOW, + MUTABLE_STATE_VALUE, SILENT_MUTATION, STATE_WRITE_IN_COMPOSITION, UNOBSERVABLE_DEPENDENCY, + UNSTABLE_PARAMETER, +}; +use crate::types::function::{FunctionType, KnownFunction}; +use crate::types::immutability::{ + is_builtin_mutable_container, is_deeply_immutable, is_stable_parameter_type, is_write_projected, +}; +use crate::types::trailing_lambda::{ + block_callee, callee_callback_is_borrowed, callee_callback_is_once, +}; +use crate::types::{ + KnownClass, ProgramEnvironment, Type, TypeContext, TypeQualifiers, infer_definition_types, + infer_expression_types, infer_scope_types, +}; + +/// the methods of the builtin mutable containers that change them in place +const CONTAINER_MUTATORS: &[&str] = &[ + "append", + "extend", + "insert", + "pop", + "remove", + "clear", + "sort", + "reverse", + "update", + "setdefault", + "popitem", + "add", + "discard", + "appendleft", + "popleft", + "rotate", + "__iadd__", + "__imul__", + "__ior__", + "__iand__", + "__isub__", + "__ixor__", +]; + +/// basedpython-ui entry point: check the scope `context` is inferring. +/// +/// `expression_type` supplies the scope's own inferred types. It is a callback +/// so the check can read the in-progress inference rather than re-enter it as a +/// query. +pub(crate) fn check_scope<'db, 'ast>( + context: &InferContext<'db, 'ast>, + index: &'ast SemanticIndex<'db>, + expression_type: impl Fn(&Expr) -> Option>, +) { + let db = context.db(); + let env = context.program_environment(); + let module = context.module(); + let scope = context.scope().file_scope_id(db); + let node = context.scope().node(db); + + // a `once` block nested in another block: its `return` goes nowhere. this + // is a property of the language, not of the framework, so it is checked + // whether or not the framework is in the program + if let NodeWithScopeKind::Function(function) = node + && let function = function.node(module) + && function.is_trailing_lambda + { + check_nested_content_block_control_flow(context, index, module, scope, function); + } + + // every other check is about the framework's observables and scopes: + // nothing to say unless the framework resolves in this program + if resolve_module_confident( + db, + env.resolver_environment(db), + &KnownModule::BasedpythonUiRuntime.name(), + ) + .is_none() + { + return; + } + + let composition = composition_of_scope(db, context.file(), index, module, scope); + + if let Some(composition) = &composition + && composition.owner_scope == scope + && let CompositionOwner::Composable(composable) = composition.owner + && let NodeWithScopeKind::Function(function) = node + { + check_parameter_stability(context, composable, function.node(module)); + } + + let mut checker = CompositionChecker { + context, + index, + module, + source: source_text(db, context.file()), + scope, + composition, + expression_type, + conditional_depth: 0, + }; + match node { + NodeWithScopeKind::Module => checker.visit_body(&module.syntax().body), + NodeWithScopeKind::Function(function) => checker.visit_body(&function.node(module).body), + NodeWithScopeKind::Class(class) => checker.visit_body(&class.node(module).body), + NodeWithScopeKind::Lambda(lambda) => checker.visit_expr(&lambda.node(module).body), + // the first iterable of a comprehension is evaluated in the enclosing + // scope; everything else is this scope's + NodeWithScopeKind::ListComprehension(comprehension) => { + let comprehension = comprehension.node(module); + checker.visit_expr(&comprehension.elt); + checker.visit_own_generators(&comprehension.generators); + } + NodeWithScopeKind::SetComprehension(comprehension) => { + let comprehension = comprehension.node(module); + checker.visit_expr(&comprehension.elt); + checker.visit_own_generators(&comprehension.generators); + } + NodeWithScopeKind::DictComprehension(comprehension) => { + let comprehension = comprehension.node(module); + if let Some(key) = comprehension.key.as_deref() { + checker.visit_expr(key); + } + checker.visit_expr(&comprehension.value); + checker.visit_own_generators(&comprehension.generators); + } + NodeWithScopeKind::GeneratorExpression(generator) => { + let generator = generator.node(module); + checker.visit_expr(&generator.elt); + checker.visit_own_generators(&generator.generators); + } + NodeWithScopeKind::ClassTypeParameters(_) + | NodeWithScopeKind::FunctionTypeParameters(_) + | NodeWithScopeKind::TypeAliasTypeParameters(_) + | NodeWithScopeKind::TypeAlias(_) => {} + } +} + +// --------------------------------------------------------------------------- +// what a scope is part of +// --------------------------------------------------------------------------- + +/// the framework entry points whose `root` argument is where a composition +/// starts +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum RootEntry { + /// `run_app(...)`: the root block of a windowed app + RunApp, + /// `compose_test(...)`: the root block of a headless test + ComposeTest, + /// `Runtime.set_root(...)`: the runtime's own entry point, which the two + /// above wrap; a test or benchmark hands it a lambda or a function + SetRoot, +} + +impl RootEntry { + /// the entry point of a known function, if it is one + fn from_known(known: KnownFunction) -> Option { + match known { + KnownFunction::BasedpythonUiRunApp => Some(Self::RunApp), + KnownFunction::BasedpythonUiComposeTest => Some(Self::ComposeTest), + _ => None, + } + } + + const fn name(self) -> &'static str { + match self { + Self::RunApp => "run_app", + Self::ComposeTest => "compose_test", + Self::SetRoot => "set_root", + } + } +} + +/// what a composition belongs to +#[derive(Clone, Copy)] +pub(crate) enum CompositionOwner<'db> { + /// a `@composable` function: its body is the composition + Composable(FunctionType<'db>), + /// the `root` of an entry point, where a composition starts without a + /// composable of its own + Root(RootEntry), +} + +impl CompositionOwner<'_> { + /// how a message names the owner: "`Counter`", "the `run_app` root" + fn describe(self, db: &dyn Db) -> String { + match self { + Self::Composable(function) => format!("`{}`", function.name(db)), + Self::Root(entry) => format!("the `{}` root", entry.name()), + } + } +} + +/// when a scope runs, relative to the composition it takes part in. Ordered +/// from the most to the least known: what a scope inherits from the scopes +/// between it and its owner is the worst timing crossed on the way +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Timing { + /// while composing, exactly as often as the owner: the owner's body, or a + /// chain of `once` blocks written in it + #[default] + Inline, + /// while composing, but any number of times: a `local` block (a keyed + /// `each`) was crossed + Local, + /// unknown: a block whose callee could not be resolved was crossed + Unknown, + /// after composition: a deferred callback — a handler block, a lambda, a + /// nested `def` — was crossed + Deferred, +} + +/// the composition a scope takes part in, and how it got there +pub(crate) struct Composition<'db> { + pub(crate) owner: CompositionOwner<'db>, + /// the scope of the owner's body — the composable's, or the root block's + pub(crate) owner_scope: FileScopeId, + /// where the owner is declared, for a secondary annotation + owner_span: Span, + /// when the scope runs, relative to the owner's composition + timing: Timing, + /// when what the scope *reads* is read, relative to the owner's + /// composition: as `timing`, except that the lambda given to `derived` / + /// `remember` reads on behalf of the composition that made it — what it + /// reads is what the computation depends on — while for every other + /// purpose it still runs later + read_timing: Timing, + /// the scope is reached through a content block written under a + /// condition, or through a comprehension: what it does happens only + /// sometimes + conditional: bool, +} + +impl Composition<'_> { + /// where the owner is declared: the composable's signature, or the + /// argument an entry point was handed as its root + pub(crate) fn owner_range(&self) -> Option { + self.owner_span.range() + } + + /// whether the scope runs while its owner is composing — nothing that runs + /// later, and nothing whose timing is unknown + pub(crate) fn runs_while_composing(&self) -> bool { + self.timing <= Timing::Local + } + + /// whether what the scope reads is read while its owner is composing: a + /// dependency of the composition, which must be something it can observe + fn reads_while_composing(&self) -> bool { + self.read_timing <= Timing::Local + } + + /// whether the scope runs exactly once per composition of its owner: it + /// runs while composing, unconditionally, and through `once` blocks alone + fn runs_once_per_composition(&self) -> bool { + self.timing == Timing::Inline && !self.conditional + } + + /// whether the scope runs after its owner has composed, when there is no + /// composition to build into + pub(crate) fn runs_after_composing(&self) -> bool { + self.timing == Timing::Deferred + } +} + +/// what a trailing-lambda block's callee makes of the block +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum BlockKind { + /// a `once` callback: the block runs inline, exactly once + Once, + /// a `local` callback: the block runs before the call returns, any number + /// of times (a keyed `each`) + Local, + /// an unborrowed callback: the callee may keep the block and run it later + Deferred, + /// the callee cannot be resolved + Opaque, + /// the `root` of `run_app` / `compose_test`: a composition of its own + Root(RootEntry), +} + +/// what `block`'s callee makes of it +pub(crate) fn block_kind<'db>( + db: &'db dyn Db, + index: &SemanticIndex<'db>, + block: &ast::StmtFunctionDef, +) -> BlockKind { + let Some(callee) = block_callee(db, index, block) else { + return BlockKind::Opaque; + }; + if let Type::FunctionLiteral(function) = callee.ty + && let Some(known) = function.known(db) + && is_composition_root(known) + && let Some(entry) = RootEntry::from_known(known) + { + return BlockKind::Root(entry); + } + if callee_callback_is_once(db, callee.ty) { + return BlockKind::Once; + } + match callee_callback_is_borrowed(db, callee.ty) { + Some(true) => BlockKind::Local, + Some(false) => BlockKind::Deferred, + None => BlockKind::Opaque, + } +} + +/// the composition `scope` takes part in, found by walking out to the +/// composable (or root block) whose composition it is, noting what is crossed +/// on the way. `None` when nothing encloses it but the module or a class. +/// +/// `file` is the file `index` and `module` belong to. Everything here is read +/// from salsa queries — a definition's inferred type, a standalone expression's +/// — never from the inference of the scope being checked, so the walk can be +/// asked from inside that inference (the checks) and from outside it (the +/// editor's invalidation hint) alike +pub(crate) fn composition_of_scope<'db, 'ast>( + db: &'db dyn Db, + file: File, + index: &'ast SemanticIndex<'db>, + module: &'ast ParsedModuleRef, + scope: FileScopeId, +) -> Option> { + let mut conditional = false; + let mut timing = Timings::default(); + // the range of the scope we came from, to see whether it sits under a + // condition in the scope now being looked at + let mut child: Option = None; + let composition = |owner, owner_scope, owner_span, timing: Timings, conditional| Composition { + owner, + owner_scope, + owner_span, + timing: timing.run, + read_timing: timing.read, + conditional, + }; + + for (id, ancestor) in index.ancestor_scopes(scope) { + match ancestor.node() { + NodeWithScopeKind::Function(function) => { + let function = function.node(module); + if let Some(child) = child { + conditional |= statement_is_conditional(&function.body, child); + } + if function.is_trailing_lambda { + match block_kind(db, index, function) { + BlockKind::Once => {} + BlockKind::Local => timing.cross(Timing::Local), + BlockKind::Deferred => timing.cross(Timing::Deferred), + BlockKind::Opaque => timing.cross(Timing::Unknown), + BlockKind::Root(entry) => { + let callee = function.trailing_lambda_callee()?; + return Some(composition( + CompositionOwner::Root(entry), + id, + Span::from(file).with_range(callee.range()), + timing, + conditional, + )); + } + } + } else { + let definition = index.expect_single_definition(function); + if let Some(composable) = + infer_definition_types(db, definition).function_type(definition) + && is_composable(db, composable) + { + return Some(composition( + CompositionOwner::Composable(composable), + id, + composable.spans(db).signature, + timing, + conditional, + )); + } + // a plain `def` handed to the runtime as its root is a + // composition of its own; any other runs when called + if let Some(span) = set_root_argument( + db, + file, + index, + module, + id, + ScopeArgument::Named(function.name.as_str()), + ) { + return Some(composition( + CompositionOwner::Root(RootEntry::SetRoot), + id, + span, + timing, + conditional, + )); + } + timing.cross(Timing::Deferred); + } + child = Some(function.range()); + } + NodeWithScopeKind::Lambda(lambda) => { + let lambda = lambda.node(module); + if let Some(span) = set_root_argument( + db, + file, + index, + module, + id, + ScopeArgument::Lambda(lambda.range()), + ) { + return Some(composition( + CompositionOwner::Root(RootEntry::SetRoot), + id, + span, + timing, + conditional, + )); + } + // the lambda given to `derived` / `remember` runs later too, + // but what it reads is what the computation depends on: a + // read of the composition that made it + if computation_kind(db, index, module, id, lambda.range()).is_some() { + timing.run = timing.run.max(Timing::Deferred); + } else { + timing.cross(Timing::Deferred); + } + child = Some(lambda.range()); + } + NodeWithScopeKind::ListComprehension(comprehension) => { + conditional = true; + child = Some(comprehension.node(module).range()); + } + NodeWithScopeKind::SetComprehension(comprehension) => { + conditional = true; + child = Some(comprehension.node(module).range()); + } + NodeWithScopeKind::DictComprehension(comprehension) => { + conditional = true; + child = Some(comprehension.node(module).range()); + } + NodeWithScopeKind::GeneratorExpression(generator) => { + conditional = true; + child = Some(generator.node(module).range()); + } + NodeWithScopeKind::Module | NodeWithScopeKind::Class(_) => return None, + NodeWithScopeKind::ClassTypeParameters(_) + | NodeWithScopeKind::FunctionTypeParameters(_) + | NodeWithScopeKind::TypeAliasTypeParameters(_) + | NodeWithScopeKind::TypeAlias(_) => {} + } + } + None +} + +/// the timings accumulated while walking out of a scope: when the scope runs, +/// and when what it reads is read (see [`Composition::read_timing`]) +#[derive(Clone, Copy, Default)] +struct Timings { + run: Timing, + read: Timing, +} + +impl Timings { + /// a callback boundary crossed on the way out, for running and reading + /// alike + fn cross(&mut self, timing: Timing) { + self.run = self.run.max(timing); + self.read = self.read.max(timing); + } +} + +/// how a scope can be the argument of a call in its enclosing scope +#[derive(Clone, Copy)] +enum ScopeArgument<'a> { + /// a lambda written as the argument, identified by its range + Lambda(TextRange), + /// a function passed by name + Named(&'a str), +} + +impl ScopeArgument<'_> { + /// whether `value`, an argument as written, is this scope + fn matches(self, value: &Expr) -> bool { + match self { + Self::Lambda(range) => value.range() == range, + Self::Named(name) => matches!(value, Expr::Name(value) if value.id.as_str() == name), + } + } +} + +/// the calls whose argument a scope can be. Each is found syntactically in +/// the scope's enclosing scope, and its callee is read from the standalone +/// inference the semantic index registers for exactly these calls — so the +/// argument's scope learns whom it was handed to without re-entering the +/// enclosing scope's inference, which may itself be waiting on this scope's, +/// for a lambda's return type +#[derive(Clone, Copy, PartialEq, Eq)] +enum ArgumentOf { + /// `.set_root()` + SetRoot, + /// `derived()` / `remember()` + Computation, +} + +impl ArgumentOf { + /// whether `func` is spelled like this call's callee — the same shapes + /// the semantic index registers + fn is_callee(self, func: &Expr) -> bool { + match self { + Self::SetRoot => { + matches!(func, Expr::Attribute(attribute) if attribute.attr.as_str() == "set_root") + } + Self::Computation => { + let name = match func { + Expr::Name(name) => name.id.as_str(), + Expr::Attribute(attribute) => attribute.attr.as_str(), + _ => return false, + }; + matches!(name, "derived" | "remember") + } + } + } + + /// the parameter the argument fills: its name and its position + const fn parameter(self) -> (&'static str, usize) { + match self { + Self::SetRoot => ("root", 0), + Self::Computation => ("compute", 0), + } + } +} + +/// The call of kind `of` in the scope enclosing `scope` whose argument is +/// `argument` — this scope, as a lambda or a name: the callee's type and the +/// argument as written. +fn enclosing_call_argument<'db, 'ast>( + db: &'db dyn Db, + index: &'ast SemanticIndex<'db>, + module: &'ast ParsedModuleRef, + scope: FileScopeId, + of: ArgumentOf, + argument: ScopeArgument<'_>, +) -> Option<(Type<'db>, &'ast Expr)> { + let parent = index.parent_scope(scope)?; + let mut finder = CallArgumentFinder { + of, + argument, + found: None, + }; + match parent.node() { + NodeWithScopeKind::Function(function) => finder.visit_body(&function.node(module).body), + NodeWithScopeKind::Module => finder.visit_body(&module.syntax().body), + NodeWithScopeKind::Class(class) => finder.visit_body(&class.node(module).body), + NodeWithScopeKind::Lambda(lambda) => finder.visit_expr(&lambda.node(module).body), + _ => return None, + } + let (call, value) = finder.found?; + let expression = index.try_expression(call.func.as_ref())?; + let callee = infer_expression_types(db, expression, TypeContext::default()) + .try_expression_type(call.func.as_ref())?; + Some((callee, value)) +} + +/// Whether the scope `scope` is handed to `Runtime.set_root` as its `root` — +/// `rt.set_root(lambda: App())`, `rt.set_root(root)` — in which case the +/// argument's span is returned. +fn set_root_argument<'db, 'ast>( + db: &'db dyn Db, + file: File, + index: &'ast SemanticIndex<'db>, + module: &'ast ParsedModuleRef, + scope: FileScopeId, + argument: ScopeArgument<'_>, +) -> Option { + let (callee, root) = + enclosing_call_argument(db, index, module, scope, ArgumentOf::SetRoot, argument)?; + let Type::BoundMethod(method) = callee else { + return None; + }; + is_set_root(db, method.function(db)).then(|| Span::from(file).with_range(root.range())) +} + +/// The computation the lambda scope `scope`, written at `lambda`, is the +/// `compute` argument of: `BasedpythonUiDerived` for `derived(lambda: ...)`, +/// `BasedpythonUiRemember` for `remember(lambda: ...)`. Both are computations +/// whose reads are dependencies of the composition that made them. `None` for +/// a lambda written anywhere else. +pub(crate) fn computation_kind<'db, 'ast>( + db: &'db dyn Db, + index: &'ast SemanticIndex<'db>, + module: &'ast ParsedModuleRef, + scope: FileScopeId, + lambda: TextRange, +) -> Option { + let (callee, _) = enclosing_call_argument( + db, + index, + module, + scope, + ArgumentOf::Computation, + ScopeArgument::Lambda(lambda), + )?; + let Type::FunctionLiteral(function) = callee else { + return None; + }; + match function.known(db) { + known + @ Some(KnownFunction::BasedpythonUiDerived | KnownFunction::BasedpythonUiRemember) => known, + _ => None, + } +} + +/// Finds the call of one kind in a scope's own statements whose argument is a +/// given lambda or name. +struct CallArgumentFinder<'a, 'ast> { + of: ArgumentOf, + argument: ScopeArgument<'a>, + found: Option<(&'ast ast::ExprCall, &'ast Expr)>, +} + +impl<'ast> Visitor<'ast> for CallArgumentFinder<'_, 'ast> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + if self.found.is_some() { + return; + } + match stmt { + // a nested scope's statements are not this scope's; the call a + // trailing-lambda block makes, and any decorator, are + Stmt::FunctionDef(function) => { + for decorator in &function.decorator_list { + self.visit_expr(&decorator.expression); + } + } + Stmt::ClassDef(_) => {} + _ => walk_stmt(self, stmt), + } + } + + fn visit_expr(&mut self, expr: &'ast Expr) { + if self.found.is_some() { + return; + } + match expr { + Expr::Lambda(_) => return, + Expr::Call(call) if self.of.is_callee(&call.func) => { + let (name, position) = self.of.parameter(); + if let Some(value) = call.arguments.find_argument_value(name, position) + && self.argument.matches(value) + { + self.found = Some((call, value)); + return; + } + } + _ => {} + } + walk_expr(self, expr); + } +} + +/// whether the statement at `target` sits under a conditional statement — +/// `if`, `for`, `while`, `try`, `match` — somewhere in `body` +fn statement_is_conditional(body: &[Stmt], target: TextRange) -> bool { + let Some(stmt) = body.iter().find(|stmt| stmt.range().contains_range(target)) else { + return false; + }; + if stmt.range() == target { + return false; + } + match stmt { + // a `finally` body runs however the `try` exited, so what it holds is + // reached as unconditionally as the `try` statement itself + Stmt::Try(try_stmt) + if try_stmt + .finalbody + .iter() + .any(|stmt| stmt.range().contains_range(target)) => + { + statement_is_conditional(&try_stmt.finalbody, target) + } + Stmt::If(_) | Stmt::For(_) | Stmt::While(_) | Stmt::Try(_) | Stmt::Match(_) => true, + Stmt::With(with) => statement_is_conditional(&with.body, target), + _ => false, + } +} + +// --------------------------------------------------------------------------- +// `content-block-control-flow` +// --------------------------------------------------------------------------- + +/// A `once` block's `return` leaves the scope the block is written in — but +/// only that one. When that scope is itself a block, the `return` leaves the +/// inner block and stops: the enclosing function keeps running and the value +/// is discarded. Report each such `return`. +fn check_nested_content_block_control_flow<'db, 'ast>( + context: &InferContext<'db, 'ast>, + index: &'ast SemanticIndex<'db>, + module: &'ast ParsedModuleRef, + scope: FileScopeId, + block: &'ast ast::StmtFunctionDef, +) { + if block_kind(context.db(), index, block) != BlockKind::Once { + return; + } + let nested_in_block = index.parent_scope(scope).is_some_and(|parent| { + matches!( + parent.node(), + NodeWithScopeKind::Function(function) if function.node(module).is_trailing_lambda + ) + }); + if !nested_in_block { + return; + } + // the scope the `return` was written to leave: the nearest enclosing + // function that is not a block + let target = index + .ancestor_scopes(scope) + .skip(1) + .find_map(|(_, ancestor)| match ancestor.node() { + NodeWithScopeKind::Function(function) => { + let function = function.node(module); + (!function.is_trailing_lambda).then(|| format!("`{}`", function.name)) + } + NodeWithScopeKind::Lambda(_) => Some("the enclosing lambda".to_owned()), + NodeWithScopeKind::Module => Some("the module".to_owned()), + _ => None, + }) + .unwrap_or_else(|| "the enclosing scope".to_owned()); + NestedReturnChecker { context, target }.visit_body(&block.body); +} + +struct NestedReturnChecker<'a, 'db, 'ast> { + context: &'a InferContext<'db, 'ast>, + /// how the message names the scope the `return` cannot leave + target: String, +} + +impl<'ast> Visitor<'ast> for NestedReturnChecker<'_, '_, 'ast> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + match stmt { + // a nested function / class is its own `return` target; a nested + // block is checked from its own scope + Stmt::FunctionDef(_) | Stmt::ClassDef(_) => {} + Stmt::Return(ret) => { + if let Some(builder) = self.context.report_lint(&CONTENT_BLOCK_CONTROL_FLOW, ret) { + builder.into_diagnostic(format_args!( + "`return` inside a nested content block leaves only the block; \ + it cannot leave {}", + self.target + )); + } + } + _ => walk_stmt(self, stmt), + } + } + + fn visit_expr(&mut self, _expr: &'ast Expr) {} +} + +// --------------------------------------------------------------------------- +// `unstable-parameter` +// --------------------------------------------------------------------------- + +/// Report each parameter of `composable` whose declared type is not stable: +/// the runtime cannot compare such an argument, so the composable is never +/// skipped. +fn check_parameter_stability<'db, 'ast>( + context: &InferContext<'db, 'ast>, + composable: FunctionType<'db>, + function: &'ast ast::StmtFunctionDef, +) { + let db = context.db(); + let env = context.program_environment(); + let signature = composable.signature(db); + let Some(signature) = signature.overloads.last() else { + return; + }; + for parameter in function.parameters.iter_non_variadic_params() { + let node = ¶meter.parameter; + if node.annotation.is_none() { + continue; + } + let Some(declared) = signature + .parameters() + .iter() + .find(|declared| declared.name() == Some(&node.name.id)) + else { + continue; + }; + let ty = declared.annotated_type(); + if is_stable_parameter_type(db, env, ty) { + continue; + } + let Some(builder) = context.report_lint(&UNSTABLE_PARAMETER, node) else { + continue; + }; + builder.into_diagnostic(format_args!( + "`{name}: {ty}` is unstable, so `{composable}` is never skipped; prefer {alternatives}", + name = node.name.id, + ty = ty.display(db, env), + composable = composable.name(db), + alternatives = stable_alternatives(db, env, ty, frozen_record(db, context.file())), + )); + } +} + +/// how a message spells "a frozen record" for the file it is reported in: +/// `.py` has no `frozen data class`, and telling a python author to write one +/// sends them looking for syntax their file cannot use +fn frozen_record(db: &dyn Db, file: ruff_db::files::File) -> &'static str { + if file.source_type(db).is_basedpython() { + "a `frozen data class`" + } else { + "a `@dataclass(frozen=True)`" + } +} + +/// the stable spellings a message suggests for an unstable parameter type. A +/// read-only view (`list[out int]`) is stable, but is not suggested: it is +/// still an `unobservable-dependency` when read while composing +fn stable_alternatives<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, + frozen: &str, +) -> String { + match container_shape(db, env, ty) { + Some(ContainerShape::List(element)) => { + format!( + "`tuple[{}, ...]`, `state_list`, or {frozen}", + element.display(db, env) + ) + } + Some(ContainerShape::Set(element)) => { + format!("`frozenset[{}]` or `state_list`", element.display(db, env)) + } + Some(ContainerShape::Dict) => format!("`state_dict` or {frozen}"), + None => format!("{frozen} or an observable"), + } +} + +/// the builtin container a type is, when the spellings a message suggests +/// depend on it +#[derive(Clone, Copy)] +enum ContainerShape<'db> { + /// `list[T]`, with its element type + List(Type<'db>), + /// `set[T]`, with its element type + Set(Type<'db>), + /// `dict[K, V]` + Dict, +} + +fn container_shape<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, +) -> Option> { + // the shape a message names is the shape inside any use-site restriction: + // `final list[str]` should still be told to prefer `tuple[str, ...]` + let ty = underlying(db, ty); + if let Some(specialization) = ty.known_specialization(db, env, KnownClass::List) + && let [element] = specialization.types(db) + { + return Some(ContainerShape::List(*element)); + } + if let Some(specialization) = ty.known_specialization(db, env, KnownClass::Set) + && let [element] = specialization.types(db) + { + return Some(ContainerShape::Set(*element)); + } + if let Some(specialization) = ty.known_specialization(db, env, KnownClass::Dict) + && let [_, _] = specialization.types(db) + { + return Some(ContainerShape::Dict); + } + None +} + +// --------------------------------------------------------------------------- +// `unobservable-dependency` +// --------------------------------------------------------------------------- + +/// what a name a composition reads, but did not bind itself, is to it +#[derive(Clone, Copy, PartialEq, Eq)] +enum DependencyKind { + /// a parameter of the composable, which its caller fills + Parameter, + /// a module-level name + Global, + /// a local of a function enclosing the composition + Captured, +} + +/// how a message suggests making a dependency of type `ty` observable: the +/// state constructor that holds a value of its shape, the immutable spelling +/// of it, or — for a value with neither — the frozen record or observable +/// that should replace it +fn observable_alternatives<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, + kind: DependencyKind, + frozen: &str, +) -> String { + let (holder, immutable) = match container_shape(db, env, ty) { + Some(ContainerShape::List(element)) => ( + "`state_list`", + format!("`tuple[{}, ...]`, {frozen}", element.display(db, env)), + ), + Some(ContainerShape::Set(element)) => ( + "`state_list`", + format!("`frozenset[{}]`", element.display(db, env)), + ), + Some(ContainerShape::Dict) => ("`state_dict`", frozen.to_owned()), + None => { + return match kind { + DependencyKind::Parameter => { + format!("pass {frozen} or an observable, or read it only in a handler") + } + DependencyKind::Global | DependencyKind::Captured => { + format!("make it {frozen} or an observable, or read it only in a handler") + } + }; + } + }; + match kind { + DependencyKind::Parameter => format!( + "hold it in state ({holder}), pass an immutable value ({immutable}), \ + or read it only in a handler" + ), + DependencyKind::Global | DependencyKind::Captured => { + format!("hold it in state ({holder}), make it immutable, or read it only in a handler") + } + } +} + +// --------------------------------------------------------------------------- +// the walk over a scope's body +// --------------------------------------------------------------------------- + +struct CompositionChecker<'a, 'db, 'ast, F> { + context: &'a InferContext<'db, 'ast>, + index: &'ast SemanticIndex<'db>, + module: &'ast ParsedModuleRef, + source: SourceText, + /// the scope being checked + scope: FileScopeId, + /// the composition the scope takes part in, if any + composition: Option>, + expression_type: F, + /// how many conditional constructs enclose what is being visited + conditional_depth: usize, +} + +impl<'db, 'ast, F> Visitor<'ast> for CompositionChecker<'_, 'db, 'ast, F> +where + F: Fn(&Expr) -> Option>, +{ + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + match stmt { + // the block's call is made here; its body is a scope of its own + Stmt::FunctionDef(function) if function.is_trailing_lambda => { + self.visit_block_call(function); + } + // a nested function does not run where it is defined: its + // decorators and defaults do + Stmt::FunctionDef(function) => { + for decorator in &function.decorator_list { + self.visit_expr(&decorator.expression); + } + for default in function + .parameters + .iter_non_variadic_params() + .filter_map(|parameter| parameter.default.as_deref()) + { + self.visit_expr(default); + } + } + Stmt::ClassDef(class) => { + for decorator in &class.decorator_list { + self.visit_expr(&decorator.expression); + } + for base in class.bases() { + self.visit_expr(base); + } + for keyword in class.keywords() { + self.visit_expr(&keyword.value); + } + } + Stmt::Assign(assign) => { + // the assigned value is known only for a single target + let value = match assign.targets.as_slice() { + [_] => Some(&*assign.value), + _ => None, + }; + for target in &assign.targets { + self.check_store(target, value); + } + walk_stmt(self, stmt); + } + Stmt::AnnAssign(assign) => { + if let Some(value) = assign.value.as_deref() { + self.check_store(&assign.target, Some(value)); + } + walk_stmt(self, stmt); + } + Stmt::AugAssign(assign) => { + self.check_augmented_store(assign); + walk_stmt(self, stmt); + } + Stmt::Delete(delete) => { + for target in &delete.targets { + self.check_delete(target); + } + walk_stmt(self, stmt); + } + Stmt::If(if_stmt) => { + self.visit_expr(&if_stmt.test); + self.conditional(|this| { + this.visit_body(&if_stmt.body); + for clause in &if_stmt.elif_else_clauses { + if let Some(test) = &clause.test { + this.visit_expr(test); + } + this.visit_body(&clause.body); + } + }); + } + Stmt::For(for_stmt) => { + self.visit_expr(&for_stmt.iter); + self.conditional(|this| { + this.visit_expr(&for_stmt.target); + this.visit_body(&for_stmt.body); + this.visit_body(&for_stmt.orelse); + }); + } + // everything but `finally` depends on how the body exited; the + // `finally` body runs whatever happened above it, so a slot in one + // runs exactly as often as the statement does + Stmt::Try(try_stmt) => { + self.conditional(|this| { + this.visit_body(&try_stmt.body); + for handler in &try_stmt.handlers { + let ast::ExceptHandler::ExceptHandler(handler) = handler; + if let Some(kind) = handler.type_.as_deref() { + this.visit_expr(kind); + } + this.visit_body(&handler.body); + } + this.visit_body(&try_stmt.orelse); + }); + self.visit_body(&try_stmt.finalbody); + } + Stmt::While(_) | Stmt::Match(_) => { + self.conditional(|this| walk_stmt(this, stmt)); + } + _ => walk_stmt(self, stmt), + } + } + + fn visit_expr(&mut self, expr: &'ast Expr) { + match expr { + // a lambda body does not run where it is written; its defaults do + Expr::Lambda(lambda) => { + for default in lambda + .parameters + .iter() + .flat_map(|parameters| parameters.iter_non_variadic_params()) + .filter_map(|parameter| parameter.default.as_deref()) + { + self.visit_expr(default); + } + } + // only a comprehension's first iterable is evaluated here; the + // rest is a scope of its own + Expr::ListComp(comprehension) => self.visit_first_iterable(&comprehension.generators), + Expr::SetComp(comprehension) => self.visit_first_iterable(&comprehension.generators), + Expr::DictComp(comprehension) => self.visit_first_iterable(&comprehension.generators), + Expr::Generator(generator) => self.visit_first_iterable(&generator.generators), + Expr::Call(call) => { + self.check_call(expr, call); + walk_expr(self, expr); + } + Expr::Name(name) if name.ctx.is_load() => self.check_dependency(expr, name), + Expr::If(if_expr) => { + self.visit_expr(&if_expr.test); + self.conditional(|this| { + this.visit_expr(&if_expr.body); + this.visit_expr(&if_expr.orelse); + }); + } + Expr::BoolOp(bool_op) => { + if let Some((first, rest)) = bool_op.values.split_first() { + self.visit_expr(first); + self.conditional(|this| { + for value in rest { + this.visit_expr(value); + } + }); + } + } + _ => walk_expr(self, expr), + } + } +} + +impl<'db, 'ast, F> CompositionChecker<'_, 'db, 'ast, F> +where + F: Fn(&Expr) -> Option>, +{ + fn db(&self) -> &'db dyn Db { + self.context.db() + } + + fn env(&self) -> &'ast ProgramEnvironment<'db> { + self.context.program_environment() + } + + fn type_of(&self, expr: &Expr) -> Option> { + (self.expression_type)(expr) + } + + /// the source text of `ranged`, for naming an expression in a message + fn text(&self, range: TextRange) -> &str { + &self.source[range] + } + + /// visit with one more conditional construct enclosing what is visited + fn conditional(&mut self, visit: impl FnOnce(&mut Self)) { + self.conditional_depth += 1; + visit(self); + self.conditional_depth -= 1; + } + + fn visit_first_iterable(&mut self, generators: &'ast [ast::Comprehension]) { + if let Some(first) = generators.first() { + self.visit_expr(&first.iter); + } + } + + /// the parts of a comprehension's generators that belong to the + /// comprehension's own scope: every iterable but the first, and the + /// conditions + fn visit_own_generators(&mut self, generators: &'ast [ast::Comprehension]) { + for (position, generator) in generators.iter().enumerate() { + if position > 0 { + self.visit_expr(&generator.iter); + } + for condition in &generator.ifs { + self.visit_expr(condition); + } + } + } + + /// the call a trailing-lambda block makes, which belongs to this scope. + /// `f(2):` is the call `f(2)`; a bare `f:` calls `f` with the block alone + fn visit_block_call(&mut self, block: &'ast ast::StmtFunctionDef) { + let Some(decorator) = block.decorator_list.first() else { + return; + }; + let expression = match &decorator.expression { + Expr::Await(await_expr) => await_expr.value.as_ref(), + expression => expression, + }; + if !expression.is_call_expr() + && let Some(ty) = self.type_of(expression) + { + self.check_callee(expression, ty, None); + } + self.visit_expr(expression); + } + + fn check_call(&mut self, expr: &'ast Expr, call: &'ast ast::ExprCall) { + let Some(callee) = self.type_of(&call.func) else { + return; + }; + self.check_callee(&call.func, callee, Some((expr, call))); + } + + /// the checks on a call: `call` is the call expression and its node when + /// the callee is called with written arguments, `None` for a bare block + /// callee (`Row:`) + fn check_callee( + &mut self, + callee_expr: &'ast Expr, + callee: Type<'db>, + call: Option<(&'ast Expr, &'ast ast::ExprCall)>, + ) { + let db = self.db(); + match callee { + Type::FunctionLiteral(function) => { + let known = function.known(db); + if let Some((expr, call)) = call { + match known { + Some(KnownFunction::BasedpythonUiState) => { + self.check_held_value(expr, call, "initial", HeldValue::State); + } + Some(KnownFunction::BasedpythonUiStateList) => { + self.check_held_value(expr, call, "initial", HeldValue::StateList); + } + Some(KnownFunction::BasedpythonUiDerived) => { + self.check_held_value(expr, call, "compute", HeldValue::State); + } + Some(KnownFunction::BasedpythonUiRemember) => { + self.check_held_value(expr, call, "compute", HeldValue::Result); + } + Some(KnownFunction::BasedpythonUiProvide) => { + if let Some(value) = call.arguments.find_argument_value("value", 1) + && let Some(value_ty) = self.type_of(value) + { + self.report_mutable_state_value(value, value_ty); + } + } + _ => {} + } + } + if let Some(known) = known + && is_slot_function(known) + { + let range = call.map_or(callee_expr.range(), |(expr, _)| expr.range()); + self.check_slot_call(range, known); + } + if is_composable(db, function) { + self.check_composition_call(callee_expr, "a composable", function); + } else if is_widget_builder(db, function) { + self.check_composition_call(callee_expr, "a builder", function); + } + } + Type::ClassLiteral(class) if class.is_known(db, KnownClass::BasedpythonUiState) => { + if let Some((expr, call)) = call { + self.check_held_value(expr, call, "initial", HeldValue::State); + } + } + Type::ClassLiteral(class) if class.is_known(db, KnownClass::BasedpythonUiDerived) => { + if let Some((expr, call)) = call { + self.check_held_value(expr, call, "compute", HeldValue::State); + } + } + Type::ClassLiteral(class) if class.is_known(db, KnownClass::BasedpythonUiStateList) => { + if let Some((expr, call)) = call { + self.check_held_value(expr, call, "initial", HeldValue::StateList); + } + } + Type::GenericAlias(alias) + if alias + .origin(db) + .is_known(db, KnownClass::BasedpythonUiState) => + { + if let Some((expr, call)) = call { + self.check_held_value(expr, call, "initial", HeldValue::State); + } + } + Type::GenericAlias(alias) + if alias + .origin(db) + .is_known(db, KnownClass::BasedpythonUiDerived) => + { + if let Some((expr, call)) = call { + self.check_held_value(expr, call, "compute", HeldValue::State); + } + } + Type::GenericAlias(alias) + if alias + .origin(db) + .is_known(db, KnownClass::BasedpythonUiStateList) => + { + if let Some((expr, call)) = call { + self.check_held_value(expr, call, "initial", HeldValue::StateList); + } + } + Type::BoundMethod(method) => { + if let Some((_, call)) = call { + self.check_method_call(call, method.self_instance(db), method.function(db)); + } + } + _ => {} + } + } + + // -- `mutable-state-value` -------------------------------------------- + + /// The value a construction call holds, read off the call's solved result: + /// what `state([1])` holds is the `T` of the `State[T]` it returns. + fn check_held_value( + &mut self, + expr: &'ast Expr, + call: &'ast ast::ExprCall, + argument: &str, + held: HeldValue, + ) { + let db = self.db(); + let env = self.env(); + let Some(argument) = call.arguments.find_argument_value(argument, 0) else { + return; + }; + let Some(result) = self.type_of(expr) else { + return; + }; + let held = match held { + HeldValue::State => state_value_type(db, env, result), + HeldValue::StateList => state_list_element_type(db, env, result), + HeldValue::Result => Some(result), + }; + if let Some(held) = held { + self.report_mutable_state_value(argument, held); + } + } + + fn report_mutable_state_value(&self, at: impl Ranged, held: Type<'db>) { + let db = self.db(); + let env = self.env(); + if is_deeply_immutable(db, env, held) { + return; + } + let Some(builder) = self.context.report_lint(&MUTABLE_STATE_VALUE, at) else { + return; + }; + builder.into_diagnostic(format_args!( + "`{}` cannot be held in state: a change to it cannot be observed; \ + use `state_list`, a `tuple`, or {frozen}", + held.display(db, env), + frozen = frozen_record(db, self.context.file()) + )); + } + + // -- calls on a receiver ---------------------------------------------- + + /// A method call on an observable writes into it (`mutable-state-value`) + /// or changes it (`state-write-in-composition`); one on a builtin + /// container changes it where nothing can see (`silent-mutation`). + fn check_method_call( + &mut self, + call: &'ast ast::ExprCall, + receiver: Type<'db>, + method: FunctionType<'db>, + ) { + let db = self.db(); + let env = self.env(); + let name = method.name(db).as_str(); + let receiver_expr = match call.func.as_ref() { + Expr::Attribute(attribute) => Some(attribute.value.as_ref()), + _ => None, + }; + + if let Some(kind) = observable_kind(db, env, receiver) { + let written = match (kind, name) { + (ObservableKind::StateList, "append") => { + call.arguments.find_argument_value("value", 0) + } + (ObservableKind::StateList, "insert") => { + call.arguments.find_argument_value("value", 1) + } + (ObservableKind::State, "set") => call.arguments.find_argument_value("new", 0), + _ => None, + }; + if let Some(written) = written + && let Some(written_ty) = self.type_of(written) + { + self.report_mutable_state_value(written, written_ty); + } + if kind.is_mutator(name) { + let written = receiver_expr.unwrap_or(&call.func); + self.report_state_write(call.range(), self.text(written.range())); + } + return; + } + + if CONTAINER_MUTATORS.contains(&name) + && is_builtin_mutable_container(db, env, receiver) + && !is_write_projected(db, env, receiver) + && !receiver_expr.is_some_and(|receiver| self.is_fresh_local(receiver)) + { + let what = format!("{}(...)", self.text(call.func.range())); + self.report_silent_mutation(call, &what, receiver); + } + } + + // -- stores ------------------------------------------------------------- + + /// A store to `target`: `value` is the assigned expression when the + /// statement assigns this one target and nothing else. + fn check_store(&mut self, target: &'ast Expr, value: Option<&'ast Expr>) { + let db = self.db(); + let env = self.env(); + match target { + Expr::Tuple(tuple) => { + for element in &tuple.elts { + self.check_store(element, None); + } + } + Expr::List(list) => { + for element in &list.elts { + self.check_store(element, None); + } + } + Expr::Starred(starred) => self.check_store(&starred.value, None), + Expr::Attribute(attribute) => { + let Some(object) = self.type_of(&attribute.value) else { + return; + }; + if let Some(kind) = observable_kind(db, env, object) { + if kind == ObservableKind::State && attribute.attr.as_str() == "value" { + if let Some(value) = value + && let Some(value_ty) = self.type_of(value) + { + self.report_mutable_state_value(value, value_ty); + } + self.report_state_write(target.range(), self.text(attribute.value.range())); + } + return; + } + let what = format!("{} = ...", self.text(attribute.range())); + self.check_attribute_store(attribute, object, &what); + } + Expr::Subscript(subscript) => { + let Some(object) = self.type_of(&subscript.value) else { + return; + }; + if let Some(kind) = observable_kind(db, env, object) { + if matches!(kind, ObservableKind::StateList | ObservableKind::StateDict) { + if let Some(value) = value + && let Some(value_ty) = self.type_of(value) + { + self.report_mutable_state_value(value, value_ty); + } + self.report_state_write(target.range(), self.text(subscript.value.range())); + } + return; + } + let what = format!("{}[...] = ...", self.text(subscript.value.range())); + self.check_subscript_store(subscript, object, &what); + } + _ => {} + } + } + + fn check_augmented_store(&mut self, assign: &'ast ast::StmtAugAssign) { + let db = self.db(); + let env = self.env(); + let op = assign.op.as_str(); + match assign.target.as_ref() { + Expr::Attribute(attribute) => { + let Some(object) = self.type_of(&attribute.value) else { + return; + }; + if let Some(kind) = observable_kind(db, env, object) { + if kind == ObservableKind::State && attribute.attr.as_str() == "value" { + self.report_state_write( + assign.target.range(), + self.text(attribute.value.range()), + ); + } + return; + } + let what = format!("{} {op}= ...", self.text(attribute.range())); + self.check_attribute_store(attribute, object, &what); + } + Expr::Subscript(subscript) => { + let Some(object) = self.type_of(&subscript.value) else { + return; + }; + if let Some(kind) = observable_kind(db, env, object) { + if matches!(kind, ObservableKind::StateList | ObservableKind::StateDict) { + self.report_state_write( + assign.target.range(), + self.text(subscript.value.range()), + ); + } + return; + } + let what = format!("{}[...] {op}= ...", self.text(subscript.value.range())); + self.check_subscript_store(subscript, object, &what); + } + // `items += [1]` rebinds `items` to the same list, changed in place + target @ Expr::Name(_) => { + let Some(ty) = self.type_of(target) else { + return; + }; + if !matches!( + assign.op, + ast::Operator::Add + | ast::Operator::Mult + | ast::Operator::BitOr + | ast::Operator::BitAnd + | ast::Operator::Sub + | ast::Operator::BitXor + ) { + return; + } + if is_builtin_mutable_container(db, env, ty) + && !is_write_projected(db, env, ty) + && !self.is_fresh_local(target) + { + let what = format!("{} {op}= ...", self.text(target.range())); + self.report_silent_mutation(assign, &what, ty); + } + } + _ => {} + } + } + + fn check_delete(&mut self, target: &'ast Expr) { + let db = self.db(); + let env = self.env(); + match target { + Expr::Tuple(tuple) => { + for element in &tuple.elts { + self.check_delete(element); + } + } + Expr::List(list) => { + for element in &list.elts { + self.check_delete(element); + } + } + Expr::Subscript(subscript) => { + let Some(object) = self.type_of(&subscript.value) else { + return; + }; + if observable_kind(db, env, object).is_some() { + return; + } + let what = format!("del {}[...]", self.text(subscript.value.range())); + self.check_subscript_store(subscript, object, &what); + } + Expr::Attribute(attribute) => { + let Some(object) = self.type_of(&attribute.value) else { + return; + }; + if observable_kind(db, env, object).is_some() { + return; + } + let what = format!("del {}", self.text(attribute.range())); + self.check_attribute_store(attribute, object, &what); + } + _ => {} + } + } + + /// A subscript store or delete on a builtin mutable container is a + /// `silent-mutation`, unless the container is a fresh local or a read-only + /// view (through which the write is already rejected). + fn check_subscript_store( + &mut self, + subscript: &'ast ast::ExprSubscript, + object: Type<'db>, + what: &str, + ) { + let db = self.db(); + let env = self.env(); + if is_builtin_mutable_container(db, env, object) + && !is_write_projected(db, env, object) + && !self.is_fresh_local(&subscript.value) + { + self.report_silent_mutation(subscript, what, object); + } + } + + /// An attribute store on an instance is a `silent-mutation` unless the + /// class is frozen (the store is already rejected), the attribute is + /// `Final` / read-only (likewise), or the instance is a fresh local. + fn check_attribute_store( + &mut self, + attribute: &'ast ast::ExprAttribute, + object: Type<'db>, + what: &str, + ) { + let db = self.db(); + let env = self.env(); + let Some((class, _)) = object + .nominal_class(db, env) + .and_then(|class| class.static_class_literal(db)) + else { + return; + }; + if class.is_frozen_dataclass(db) == Some(true) || class.is_enum_variant(db) { + return; + } + let member = object.member(db, env, attribute.attr.as_str()); + if member + .qualifiers + .intersects(TypeQualifiers::FINAL | TypeQualifiers::READ_ONLY) + { + return; + } + if self.is_fresh_local(&attribute.value) { + return; + } + self.report_silent_mutation(attribute, what, object); + } + + // -- reports ------------------------------------------------------------ + + /// `silent-mutation`: `what` names the mutation (`items.append(...)`), + /// `mutated` is the type changed in place + fn report_silent_mutation(&self, at: impl Ranged, what: &str, mutated: Type<'db>) { + let db = self.db(); + let env = self.env(); + let Some(composition) = &self.composition else { + return; + }; + let Some(builder) = self.context.report_lint(&SILENT_MUTATION, at) else { + return; + }; + let owner = composition.owner.describe(db); + let mut diagnostic = builder.into_diagnostic(format_args!( + "`{what}` mutates `{}` in place, which {owner}'s composition cannot observe; \ + mutate a `StateList` or rebuild an immutable value", + mutated.display(db, env), + )); + diagnostic.annotate( + Annotation::secondary(composition.owner_span.clone()) + .message(format_args!("{owner} composes here")), + ); + } + + /// `state-write-in-composition`: `written` names the observable written + fn report_state_write(&self, at: TextRange, written: &str) { + let db = self.db(); + let Some(composition) = &self.composition else { + return; + }; + if !composition.runs_while_composing() { + return; + } + let Some(builder) = self.context.report_lint(&STATE_WRITE_IN_COMPOSITION, at) else { + return; + }; + builder.into_diagnostic(format_args!( + "`{written}` is written while {} is composing; \ + move the write into an event handler or an effect", + composition.owner.describe(db), + )); + } + + /// `conditional-slot`: a slot call that does not run exactly when its + /// composition scope does + fn check_slot_call(&self, at: TextRange, slot: KnownFunction) { + let Some(composition) = &self.composition else { + return; + }; + if self.conditional_depth == 0 && composition.runs_once_per_composition() { + return; + } + let Some(builder) = self.context.report_lint(&CONDITIONAL_SLOT, at) else { + return; + }; + builder.into_diagnostic(format_args!( + "`{}()` under a condition: it will be created and disposed as the condition changes", + slot.name() + )); + } + + /// `composable-outside-composition`: a composable or builder called where + /// nothing is composing — outside every composition, or in a callback that + /// runs after it + fn check_composition_call(&self, callee: &'ast Expr, kind: &str, function: FunctionType<'db>) { + let outside = self + .composition + .as_ref() + .is_none_or(Composition::runs_after_composing); + if !outside { + return; + } + let Some(builder) = self + .context + .report_lint(&COMPOSABLE_OUTSIDE_COMPOSITION, callee) + else { + return; + }; + builder.into_diagnostic(format_args!( + "`{}` is {kind} and can only be called while composing", + function.name(self.db()) + )); + } + + // -- `unobservable-dependency` ------------------------------------------ + + /// A load of a name the composition did not bind itself — a parameter of + /// the composable, a module global, a local captured from an enclosing + /// function — is a dependency of the composition, and must be something + /// it can observe: a deeply immutable value cannot change, an observable + /// notifies when it does, anything else changes without telling anyone. + fn check_dependency(&self, expr: &'ast Expr, name: &'ast ast::ExprName) { + let db = self.db(); + let env = self.env(); + let Some(composition) = &self.composition else { + return; + }; + if !composition.reads_while_composing() { + return; + } + let Some(kind) = self.dependency_kind(composition, name.id.as_str()) else { + return; + }; + let Some(ty) = self.type_of(expr) else { + return; + }; + // a module is a namespace read through, not a value that changes + if matches!(ty, Type::ModuleLiteral(_)) { + return; + } + if is_deeply_immutable(db, env, ty) || observable_kind(db, env, ty).is_some() { + return; + } + let Some(builder) = self.context.report_lint(&UNOBSERVABLE_DEPENDENCY, name) else { + return; + }; + let owner = composition.owner.describe(db); + let what = match kind { + DependencyKind::Parameter => format!("`{}: {}`", name.id, ty.display(db, env)), + DependencyKind::Global | DependencyKind::Captured => { + format!("`{}` (`{}`)", name.id, ty.display(db, env)) + } + }; + let mut diagnostic = builder.into_diagnostic(format_args!( + "{what} is read while {owner} composes, but nothing observes a change to it; {}", + observable_alternatives(db, env, ty, kind, frozen_record(db, self.context.file())), + )); + diagnostic.annotate( + Annotation::secondary(composition.owner_span.clone()) + .message(format_args!("{owner} composes here")), + ); + } + + /// What `name`, loaded in this scope, is to `composition`: a parameter of + /// its composable, a module global, or a local captured from a function + /// enclosing it. `None` when the composition binds the name itself — a + /// local of the body, of a block written in it, of a comprehension, a + /// block's own parameter: this run's own value, whatever its origin — or + /// when nothing in the file binds it: a builtin, or a member the block's + /// receiver supplies. + fn dependency_kind( + &self, + composition: &Composition<'db>, + name: &str, + ) -> Option { + for (id, _) in self.index.visible_ancestor_scopes(self.scope) { + let table = self.index.place_table(id); + let Some(symbol_id) = table.symbol_id(name) else { + continue; + }; + let symbol = table.symbol(symbol_id); + if symbol.is_global() { + return Some(DependencyKind::Global); + } + if symbol.is_nonlocal() || !symbol.is_bound() { + continue; + } + if id.is_global() { + return Some(DependencyKind::Global); + } + if !self.is_composition_scope(composition, id) { + return Some(DependencyKind::Captured); + } + // bound inside the composition: the composable's own parameter + // is what its caller passed; anything else is this run's value + let is_parameter = id == composition.owner_scope + && matches!(composition.owner, CompositionOwner::Composable(_)) + && matches!( + self.index.scope(id).node(), + NodeWithScopeKind::Function(function) + if function.node(self.module).parameters.includes(name) + ); + return is_parameter.then_some(DependencyKind::Parameter); + } + None + } + + /// whether `scope` runs as part of `composition`: it is this scope, or + /// one of those between it and the composition's owner + fn is_composition_scope(&self, composition: &Composition<'db>, scope: FileScopeId) -> bool { + for (id, _) in self.index.ancestor_scopes(self.scope) { + if id == scope { + return true; + } + if id == composition.owner_scope { + return false; + } + } + false + } + + // -- fresh locals ------------------------------------------------------- + + /// Whether `receiver` is rooted in a name that the composition itself + /// binds to a fresh value — a display, a comprehension, a constructor + /// call — in this scope or one of the scopes between it and the + /// composition's owner. Mutating such a value is mutating something no + /// one else holds. + fn is_fresh_local(&self, receiver: &Expr) -> bool { + let Some(name) = root_name(receiver) else { + return false; + }; + let Some(composition) = &self.composition else { + return false; + }; + for (id, scope) in self.index.ancestor_scopes(self.scope) { + let body: &[Stmt] = match scope.node() { + NodeWithScopeKind::Function(function) => { + let function = function.node(self.module); + if function.parameters.includes(name) { + return false; + } + &function.body + } + NodeWithScopeKind::Lambda(lambda) => { + let lambda = lambda.node(self.module); + if lambda + .parameters + .as_deref() + .is_some_and(|parameters| parameters.includes(name)) + { + return false; + } + &[] + } + NodeWithScopeKind::Module | NodeWithScopeKind::Class(_) => return false, + _ => &[], + }; + let mut scan = BindingScan { + name, + found: false, + fresh: true, + type_of: |expr: &Expr| self.type_in_scope(id, expr), + }; + scan.visit_body(body); + if scan.found { + return scan.fresh; + } + if id == composition.owner_scope { + return false; + } + } + false + } + + /// the type of `expr` in `scope`: this scope's in-progress inference, or + /// an enclosing scope's own. + /// + /// Asking an *enclosing* scope for its types from inside this one is only + /// safe while the enclosing scope's inference does not, in turn, wait on + /// this scope's — which would close a cycle through `infer_scope_types`. + /// Two things keep it open. A trailing-lambda block's callback is required + /// to return `None` (`trailing-lambda-return-type`), so the enclosing scope + /// never needs a block body's result; and a block reads its own callee from + /// the standalone inference the semantic index registers for it, never from + /// the enclosing scope. A lambda's return type *is* needed by the scope + /// that writes it, but a lambda body binds no names of its own that this + /// walk would ask about — it stops at the first parameter or binding it + /// finds. If a block ever gains a real return type, this must move to the + /// standalone-expression route that [`enclosing_call_argument`] uses + fn type_in_scope(&self, scope: FileScopeId, expr: &Expr) -> Option> { + if scope == self.scope { + return self.type_of(expr); + } + let db = self.db(); + let scope = scope.to_scope_id(db, self.context.program_file()); + infer_scope_types(db, scope, TypeContext::default()).try_expression_type(expr) + } +} + +/// what a construction call holds, read off its result type +#[derive(Clone, Copy)] +enum HeldValue { + /// the `T` of the `State[T]` / `Derived[T]` returned + State, + /// the `T` of the `StateList[T]` returned + StateList, + /// the result itself (`remember`) + Result, +} + +/// the name an attribute / subscript chain is rooted in: `items` for +/// `items[0].children` +fn root_name(expr: &Expr) -> Option<&str> { + match expr { + Expr::Name(name) => Some(name.id.as_str()), + Expr::Attribute(attribute) => root_name(&attribute.value), + Expr::Subscript(subscript) => root_name(&subscript.value), + _ => None, + } +} + +/// Scans a scope's own statements for the bindings of one name, deciding +/// whether every one of them binds a fresh value. Nested scopes bind their own +/// names and are not entered. +struct BindingScan<'a, F> { + name: &'a str, + /// whether the scope binds the name at all + found: bool, + /// whether every binding found so far is fresh + fresh: bool, + type_of: F, +} + +impl<'db, 'ast, F> BindingScan<'_, F> +where + F: Fn(&Expr) -> Option>, +{ + fn bind(&mut self, target: &'ast Expr, value: Option<&'ast Expr>) { + match target { + Expr::Name(name) if name.id.as_str() == self.name => { + self.found = true; + self.fresh &= value.is_some_and(|value| self.is_fresh_value(value)); + } + Expr::Tuple(tuple) => { + for element in &tuple.elts { + self.bind(element, None); + } + } + Expr::List(list) => { + for element in &list.elts { + self.bind(element, None); + } + } + Expr::Starred(starred) => self.bind(&starred.value, None), + _ => {} + } + } + + fn bind_name(&mut self, name: &str) { + if name == self.name { + self.found = true; + self.fresh = false; + } + } + + /// a value nothing else can hold: a display, a comprehension, or the + /// instance a constructor call just made + fn is_fresh_value(&self, value: &Expr) -> bool { + match value { + Expr::List(_) + | Expr::Dict(_) + | Expr::Set(_) + | Expr::ListComp(_) + | Expr::DictComp(_) + | Expr::SetComp(_) => true, + Expr::Call(call) => matches!( + (self.type_of)(&call.func), + Some(Type::ClassLiteral(_) | Type::GenericAlias(_)) + ), + Expr::Named(named) => self.is_fresh_value(&named.value), + _ => false, + } + } +} + +impl<'db, 'ast, F> Visitor<'ast> for BindingScan<'_, F> +where + F: Fn(&Expr) -> Option>, +{ + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + match stmt { + Stmt::FunctionDef(function) => { + if !function.is_trailing_lambda { + self.bind_name(function.name.as_str()); + } + } + Stmt::ClassDef(class) => self.bind_name(class.name.as_str()), + Stmt::Assign(assign) => { + let value = match assign.targets.as_slice() { + [_] => Some(&*assign.value), + _ => None, + }; + for target in &assign.targets { + self.bind(target, value); + } + } + Stmt::AnnAssign(assign) => { + if let Some(value) = assign.value.as_deref() { + self.bind(&assign.target, Some(value)); + } + } + Stmt::For(for_stmt) => { + self.bind(&for_stmt.target, None); + walk_stmt(self, stmt); + } + Stmt::With(with) => { + for item in &with.items { + if let Some(target) = item.optional_vars.as_deref() { + self.bind(target, None); + } + } + walk_stmt(self, stmt); + } + Stmt::Try(try_stmt) => { + for handler in &try_stmt.handlers { + let ast::ExceptHandler::ExceptHandler(handler) = handler; + if let Some(name) = &handler.name { + self.bind_name(name.as_str()); + } + } + walk_stmt(self, stmt); + } + Stmt::Import(import) => { + for alias in &import.names { + let bound = alias.asname.as_ref().map_or_else( + || alias.name.split('.').next().unwrap_or(""), + |name| name.as_str(), + ); + self.bind_name(bound); + } + } + Stmt::ImportFrom(import) => { + for alias in &import.names { + let bound = alias.asname.as_ref().unwrap_or(&alias.name); + self.bind_name(bound.as_str()); + } + } + Stmt::Global(global) => { + for name in &global.names { + self.bind_name(name.as_str()); + } + } + Stmt::Nonlocal(nonlocal) => { + for name in &nonlocal.names { + self.bind_name(name.as_str()); + } + } + _ => walk_stmt(self, stmt), + } + } + + // a walrus inside an expression is not looked for: a name it binds is + // taken as not bound here, which errs towards reporting + fn visit_expr(&mut self, _expr: &'ast Expr) {} +} diff --git a/crates/ty_python_semantic/src/types/context_params.rs b/crates/ty_python_semantic/src/types/context_params.rs index 505fc9f028..aecfa3ade1 100644 --- a/crates/ty_python_semantic/src/types/context_params.rs +++ b/crates/ty_python_semantic/src/types/context_params.rs @@ -37,7 +37,7 @@ use crate::Db; use crate::types::ProgramEnvironment; use crate::types::receivers::{ImplicitReceiverName, implicit_receiver_name}; use crate::types::soundness::single_signature; -use crate::types::trailing_lambda::{enclosing_block_callee_type, trailing_lambda_it_type}; +use crate::types::trailing_lambda::{enclosing_block_callee, trailing_lambda_it_type}; use crate::types::{Type, binding_type}; /// the outcome of resolving one unmatched `context` parameter at a call site @@ -317,8 +317,8 @@ fn collect_block_candidates<'db>( }); } - if let Some(callee_ty) = enclosing_block_callee_type(db, scope) - && let Some(ty) = trailing_lambda_it_type(db, callee_ty) + if let Some(callee) = enclosing_block_callee(db, scope) + && let Some(ty) = trailing_lambda_it_type(db, callee) { out.push(Candidate { name: Name::new_static("it"), diff --git a/crates/ty_python_semantic/src/types/conversions.rs b/crates/ty_python_semantic/src/types/conversions.rs index 4559689b44..2bf17f4fc3 100644 --- a/crates/ty_python_semantic/src/types/conversions.rs +++ b/crates/ty_python_semantic/src/types/conversions.rs @@ -30,7 +30,7 @@ use ruff_db::files::File; use ruff_python_ast as ast; use ruff_text_size::{Ranged, TextRange}; -use ty_module_resolver::{ModuleName, resolve_module}; +use ty_module_resolver::{ModuleName, file_to_module, resolve_module}; use ty_python_core::semantic_index; use crate::Db; @@ -598,27 +598,56 @@ pub(crate) fn returned_value_at( } /// the search state for [`imported_module_spelling`]: the first import statement -/// resolving to `target` wins +/// resolving to `target` wins; failing that, the first one resolving to a +/// package that contains `target` struct ImportSpelling<'a> { db: &'a dyn Db, from_file: File, target: File, + /// `target`'s absolute module name, for the containing-package search. + /// `None` when the file is not a module of any search path + target_name: Option, + /// the spelling of an import of `target` itself found: Option, + /// the spelling of `target` through an import of a package containing it + enclosing: Option, } impl ImportSpelling<'_> { - fn resolves(&self, name: &ModuleName) -> bool { + /// record what the import of `name`, written as `written`, says about + /// `target`: the exact spelling when it *is* `target`, else a spelling + /// through it when it is a package containing `target` + fn consider(&mut self, name: &ModuleName, written: &str) { let db = self.db; - resolve_module( - self.db, + let Some(module) = resolve_module( + db, ImportingFile::File( self.from_file, db.program_file(self.from_file).resolver_environment(db), ), name, - ) - .and_then(|module| module.file(self.db)) - == Some(self.target) + ) else { + return; + }; + if module.file(db) == Some(self.target) { + self.found = Some(written.to_owned()); + return; + } + if self.enclosing.is_some() { + return; + } + let Some(rest) = self + .target_name + .as_ref() + .filter(|target_name| *target_name != name) + .and_then(|target_name| target_name.relative_to(name)) + else { + return; + }; + // extend the written spelling rather than the absolute name, so a + // relative one stays relative: `.` + `geometry` is `.geometry` + let separator = if written.ends_with('.') { "" } else { "." }; + self.enclosing = Some(format!("{written}{separator}{}", rest.as_str())); } } @@ -631,32 +660,33 @@ impl<'ast> ast::visitor::Visitor<'ast> for ImportSpelling<'_> { match stmt { ast::Stmt::Import(import) => { for alias in &import.names { - if let Some(name) = ModuleName::new(&alias.name) - && self.resolves(&name) - { - self.found = Some(alias.name.to_string()); - return; + if let Some(name) = ModuleName::new(&alias.name) { + self.consider(&name, &alias.name); + if self.found.is_some() { + return; + } } } } ast::Stmt::ImportFrom(import) => { if let Ok(name) = ModuleName::from_import_statement( - self.db, + db, ImportingFile::File( self.from_file, db.program_file(self.from_file).resolver_environment(db), ), import, - ) && self.resolves(&name) - { + ) { // keep the leading dots: a relative import is how this file // addresses the module, and the absolute name may not resolve - let mut spelling = ".".repeat(import.level as usize); + let mut written = ".".repeat(import.level as usize); if let Some(module) = &import.module { - spelling.push_str(module); + written.push_str(module); + } + self.consider(&name, &written); + if self.found.is_some() { + return; } - self.found = Some(spelling); - return; } } _ => {} @@ -672,6 +702,15 @@ impl<'ast> ast::visitor::Visitor<'ast> for ImportSpelling<'_> { /// directory that is not an importable package still resolves for the checker /// (`target/mod.by` → `target.mod`), while the interpreter running the output only /// sees `mod` — and a relative import has no absolute spelling at all. +/// +/// When no import names `target` itself, one may name a package *containing* +/// it: a class reached through a re-export — `from pkg import Dp`, with `Dp` +/// declared in `pkg.geometry` and re-exported by `pkg/__init__` — is declared in +/// a module the file never spells. That import's own spelling is then extended +/// with the rest of the path (`pkg` → `pkg.geometry`, `.` → `.geometry`): a +/// module the file imports is importable at runtime, and so is a module inside +/// it, which is the promise the absolute name alone cannot make. An import of +/// the module itself always wins over one of a package around it. pub(crate) fn imported_module_spelling( db: &dyn Db, from_file: File, @@ -679,11 +718,15 @@ pub(crate) fn imported_module_spelling( ) -> Option { let module = ruff_db::parsed::parsed_module(db, db.program_file(from_file).python_file(db)).load(db); + let target_name = file_to_module(db, db.program_file(target).resolver_file(db)) + .map(|module| module.name(db).clone()); let mut spelling = ImportSpelling { db, from_file, target, + target_name, found: None, + enclosing: None, }; for stmt in &module.syntax().body { ast::visitor::Visitor::visit_stmt(&mut spelling, stmt); @@ -691,7 +734,7 @@ pub(crate) fn imported_module_spelling( break; } } - spelling.found + spelling.found.or(spelling.enclosing) } /// every module named by a `from import ...` statement anywhere in diff --git a/crates/ty_python_semantic/src/types/dedicated/basedpython_ui.rs b/crates/ty_python_semantic/src/types/dedicated/basedpython_ui.rs new file mode 100644 index 0000000000..c9c6a0acd8 --- /dev/null +++ b/crates/ty_python_semantic/src/types/dedicated/basedpython_ui.rs @@ -0,0 +1,449 @@ +//! dedicated basedpython-ui support — recognising the framework's observables +//! and composition scopes +//! +//! the framework (`basedpython_ui`) is a compose-style ui library: a +//! `@composable` function describes a piece of ui, and re-runs whenever one of +//! the observables it reads — a `State[T]`, `StateList[T]`, `StateDict[K, V]`, +//! `Derived[T]` or `Ambient[T]` — changes. nothing framework-specific is +//! encoded beyond that: these queries answer "is this value an observable", +//! "what does it hold" and "is this function a composition scope", and every +//! ui-specific check builds on them. detection is semantic — the resolved mro +//! and the decorator's resolved function — never import-string matching, so +//! aliases, re-exports and subclasses all classify correctly +//! +//! unlike the other frameworks here, `basedpython_ui` is recognised on a +//! first-party search path too ([`KnownModule::is_framework`]), because it is +//! developed in place +//! +//! [`KnownModule::is_framework`]: ty_module_resolver::KnownModule::is_framework + +use ty_module_resolver::{KnownModule, file_to_module}; + +use crate::Db; +use crate::types::function::{FunctionDecorators, KnownFunction}; +use crate::types::{ClassBase, FunctionType, KnownClass, ProgramEnvironment, Type}; + +/// which of the framework's observables a value is +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ObservableKind { + /// `State[T]`: a cell, read and written through `.value` + State, + /// `StateList[T]`: an observable list of immutable elements + StateList, + /// `StateDict[K, V]`: an observable mapping of immutable keys and values + StateDict, + /// `Derived[T]`: a memoised computation over state, read through `.value` + Derived, + /// `Ambient[T]`: a tree-scoped value, read through `.current` + Ambient, +} + +impl ObservableKind { + /// the class that declares this observable, in `basedpython_ui.runtime` + const fn class(self) -> KnownClass { + match self { + Self::State => KnownClass::BasedpythonUiState, + Self::StateList => KnownClass::BasedpythonUiStateList, + Self::StateDict => KnownClass::BasedpythonUiStateDict, + Self::Derived => KnownClass::BasedpythonUiDerived, + Self::Ambient => KnownClass::BasedpythonUiAmbient, + } + } + + /// the framework's observable classes, each declared in `basedpython_ui.runtime` + const ALL: [Self; 5] = [ + Self::State, + Self::StateList, + Self::StateDict, + Self::Derived, + Self::Ambient, + ]; + + /// the methods that change this observable — every one of them notifies + /// the observable's readers, which is what makes writing one during + /// composition an error, and what makes a call to one a write worth + /// tracing to its readers + const fn mutators(self) -> &'static [&'static str] { + match self { + Self::State => &["set", "update"], + Self::StateList => &[ + "append", + "insert", + "remove_at", + "remove", + "pop", + "clear", + "__setitem__", + ], + Self::StateDict => &[ + "remove", + "pop", + "clear", + "update", + "setdefault", + "__setitem__", + ], + Self::Derived | Self::Ambient => &[], + } + } + + /// whether `method` is one of this observable's [mutators](Self::mutators) + pub(crate) fn is_mutator(self, method: &str) -> bool { + self.mutators().contains(&method) + } + + /// whether `method` mutates *some* observable: a call to a method of this + /// name is worth typing to see whether it is a write + pub(crate) fn is_any_mutator(method: &str) -> bool { + Self::ALL.iter().any(|kind| kind.is_mutator(method)) + } +} + +/// `ty` without a use-site restriction or alias around it: a `final +/// StateList[int]` (what a constructor call infers under `let`) is an +/// observable exactly as the `StateList[int]` inside is. +/// +/// Every predicate that asks what shape a value *is* — observable, builtin +/// container, read-only view — has to look through these wrappers, or a +/// `final list[out int]` reads as neither a list nor a view +pub(crate) fn underlying<'db>(db: &'db dyn Db, ty: Type<'db>) -> Type<'db> { + match ty { + Type::Restricted(restricted) => underlying(db, restricted.value_type(db)), + Type::TypeAlias(alias) => underlying(db, alias.value_type(db)), + _ => ty, + } +} + +/// the observable `ty` is an instance of — a `State[T]`, `StateList[T]`, +/// `StateDict[K, V]`, `Derived[T]` or `Ambient[T]`, directly or through a +/// subclass — or `None` for anything else +pub(crate) fn observable_kind<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, +) -> Option { + let (class, specialization) = underlying(db, ty) + .nominal_class(db, env) + .and_then(|class| class.static_class_literal(db))?; + class + .iter_mro(db, specialization) + .filter_map(ClassBase::into_class) + .find_map(|base| { + ObservableKind::ALL + .into_iter() + .find(|observable| base.is_known(db, observable.class())) + }) +} + +/// whether `ty` is an instance of one of the framework's observables — a +/// `State[T]`, `StateList[T]`, `StateDict[K, V]`, `Derived[T]` or `Ambient[T]`, +/// directly or through a subclass +pub(crate) fn is_observable_instance<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, +) -> bool { + observable_kind(db, env, ty).is_some() +} + +/// the value a `State[T]` or `Derived[T]` holds — the `T` — when `ty` is an +/// instance of either. `None` for anything else, including the collection +/// observables (`StateList`, `StateDict`), whose element types are not a single +/// value +pub(crate) fn state_value_type<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, +) -> Option> { + [ + KnownClass::BasedpythonUiState, + KnownClass::BasedpythonUiDerived, + ] + .into_iter() + .find_map(|holder| { + let specialization = underlying(db, ty).known_specialization(db, env, holder)?; + let [value] = specialization.types(db) else { + return None; + }; + Some(*value) + }) +} + +/// the element type of a `StateList[T]` — the `T` — when `ty` is an instance +/// of one. `None` for anything else +pub(crate) fn state_list_element_type<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, +) -> Option> { + let specialization = + underlying(db, ty).known_specialization(db, env, KnownClass::BasedpythonUiStateList)?; + let [element] = specialization.types(db) else { + return None; + }; + Some(*element) +} + +/// whether `function` is decorated with the framework's `@composable`, resolved +/// through the decorator's type ([`FunctionDecorators::COMPOSABLE`]), so an +/// alias or re-export of the decorator counts +pub(crate) fn is_composable<'db>(db: &'db dyn Db, function: FunctionType<'db>) -> bool { + function.has_known_decorator(db, FunctionDecorators::COMPOSABLE) +} + +/// whether `function` is one of the framework's widget builders (`Text`, +/// `Button`, `Column`, …), which emits into the composition being built and so, +/// like a composable, can only be called while composing. +/// +/// Resolved through the framework's `@builder` decorator +/// ([`FunctionDecorators::UI_BUILDER`]), the same way a composable is resolved +/// through `@composable`. Being declared in `basedpython_ui.widgets` is not +/// enough on its own: that module is free to hold ordinary helpers, and a +/// helper that emits nothing must stay callable outside a composition +pub(crate) fn is_widget_builder<'db>(db: &'db dyn Db, function: FunctionType<'db>) -> bool { + function.has_known_decorator(db, FunctionDecorators::UI_BUILDER) +} + +/// whether `function` is `basedpython_ui.runtime.Runtime.set_root` — the +/// runtime's own entry point, whose `root` argument is composed as the root +/// of the composition (what `run_app` / `compose_test` wrap). Resolved by the +/// method's declaring module rather than by a known class, so the check needs +/// nothing but the method's definition +pub(crate) fn is_set_root<'db>(db: &'db dyn Db, function: FunctionType<'db>) -> bool { + function.name(db) == "set_root" + && file_to_module(db, function.program_file(db).resolver_file(db)) + .and_then(|module| module.known(db)) + == Some(KnownModule::BasedpythonUiRuntime) +} + +/// whether `known` is one of the framework's *slot* functions — `state`, +/// `state_list`, `state_dict`, `derived`, `remember` and the effects — whose +/// result is remembered per call site for the lifetime of the enclosing +/// composition scope +pub(crate) const fn is_slot_function(known: KnownFunction) -> bool { + matches!( + known, + KnownFunction::BasedpythonUiState + | KnownFunction::BasedpythonUiStateList + | KnownFunction::BasedpythonUiStateDict + | KnownFunction::BasedpythonUiDerived + | KnownFunction::BasedpythonUiRemember + | KnownFunction::BasedpythonUiLaunchedEffect + | KnownFunction::BasedpythonUiDisposableEffect + | KnownFunction::BasedpythonUiSideEffect + ) +} + +/// whether `known` is one of the framework's entry points — `run_app`, +/// `compose_test` — whose `root` block is where a composition starts +pub(crate) const fn is_composition_root(known: KnownFunction) -> bool { + matches!( + known, + KnownFunction::BasedpythonUiRunApp | KnownFunction::BasedpythonUiComposeTest + ) +} + +#[cfg(test)] +mod tests { + use ruff_db::files::system_path_to_file; + use ruff_db::parsed::parsed_module; + use ruff_python_ast as ast; + use ty_python_core::semantic_index; + + use super::*; + use crate::db::tests::{TestDb, TestDbBuilder}; + use crate::types::dedicated::role::{FunctionFrameworkRole, function_framework_role}; + use crate::types::infer_definition_types; + use crate::{HasType, SemanticModel}; + + const RUNTIME_STUB: &str = "\ +class State[T]: + value: T + def __init__(self, initial: T) -> None: ... +class StateList[T]: ... +class StateDict[K, V]: ... +class Derived[T]: + value: T +class Ambient[T]: ... +def composable[F](fn: F) -> F: ... +"; + + const INIT_STUB: &str = "\ +from .runtime import State as State, StateList as StateList, StateDict as StateDict, \ +Derived as Derived, Ambient as Ambient, composable as composable +"; + + /// a db with the mock framework installed in site-packages and `source` as + /// `/src/main.py` + fn installed_framework(source: &str) -> anyhow::Result { + TestDbBuilder::new() + .with_site_packages("/sp") + .with_file("/sp/basedpython_ui/__init__.pyi", INIT_STUB) + .with_file("/sp/basedpython_ui/runtime.pyi", RUNTIME_STUB) + .with_file("/src/main.py", source) + .build() + } + + /// a db with the mock framework developed in place — a first-party package + /// beside `source`, which is `/src/main.py` + fn first_party_framework(source: &str) -> anyhow::Result { + TestDbBuilder::new() + .with_file("/src/basedpython_ui/__init__.py", INIT_STUB) + .with_file("/src/basedpython_ui/runtime.py", RUNTIME_STUB) + .with_file("/src/main.py", source) + .build() + } + + /// the inferred type of the value of the last assignment in `/src/main.py` + fn last_assigned_type(db: &TestDb) -> Type<'_> { + let file = system_path_to_file(db, "/src/main.py").expect("main.py should exist"); + let module = parsed_module(db, db.program_file(file).python_file(db)).load(db); + let model = SemanticModel::new(db, crate::Db::program_file(db, file)); + let assignment = module + .suite() + .iter() + .rev() + .find_map(ast::Stmt::as_assign_stmt) + .expect("source should end with an assignment"); + assignment + .value + .inferred_type(&model) + .expect("assigned value should infer a type") + } + + /// the function type of the last top-level `def` in `/src/main.py` + fn last_function_type(db: &TestDb) -> FunctionType<'_> { + let file = system_path_to_file(db, "/src/main.py").expect("main.py should exist"); + let module = parsed_module(db, db.program_file(file).python_file(db)).load(db); + let index = semantic_index(db, db.program_file(file)); + let function_node = module + .suite() + .iter() + .rev() + .find_map(ast::Stmt::as_function_def_stmt) + .expect("source should define a function"); + let definition = index.expect_single_definition(function_node); + infer_definition_types(db, definition) + .function_type(definition) + .expect("a `@composable` function should keep its function-literal type") + } + + #[test] + fn installed_state_instance_is_observable() -> anyhow::Result<()> { + let db = installed_framework("from basedpython_ui import State\nx = State(1)\n")?; + let ty = last_assigned_type(&db); + let env = db.program_environment(); + assert!(is_observable_instance(&db, &env, ty)); + assert_eq!( + state_value_type(&db, &env, ty).map(|value| value.display(&db, &env).to_string()), + Some("int".to_owned()) + ); + Ok(()) + } + + #[test] + fn every_observable_is_recognised_through_the_package_re_export() -> anyhow::Result<()> { + for (name, known) in [ + ("State[int]", KnownClass::BasedpythonUiState), + ("StateList[int]", KnownClass::BasedpythonUiStateList), + ("StateDict[str, int]", KnownClass::BasedpythonUiStateDict), + ("Derived[int]", KnownClass::BasedpythonUiDerived), + ("Ambient[int]", KnownClass::BasedpythonUiAmbient), + ] { + let db = installed_framework(&format!( + "from basedpython_ui import State, StateList, StateDict, Derived, Ambient\n\ + def make() -> {name}: ...\nx = make()\n" + ))?; + let ty = last_assigned_type(&db); + let env = db.program_environment(); + let class = ty + .nominal_class(&db, &env) + .and_then(|class| class.static_class_literal(&db)) + .map(|(class, _)| class) + .expect("an observable should infer a nominal instance"); + assert!(class.is_known(&db, known), "`{name}` should be `{known:?}`"); + assert!( + is_observable_instance(&db, &env, ty), + "`{name}` is an observable" + ); + } + Ok(()) + } + + #[test] + fn subclass_of_an_observable_is_observable_but_holds_no_single_value() -> anyhow::Result<()> { + let db = installed_framework( + "from basedpython_ui import StateList\nclass Items(StateList[str]): ...\nx = Items()\n", + )?; + let ty = last_assigned_type(&db); + let env = db.program_environment(); + assert!(is_observable_instance(&db, &env, ty)); + assert_eq!(state_value_type(&db, &env, ty), None); + Ok(()) + } + + #[test] + fn ordinary_instance_is_not_observable() -> anyhow::Result<()> { + let db = installed_framework( + "from basedpython_ui import State\nclass Plain: ...\nx = Plain()\n", + )?; + let ty = last_assigned_type(&db); + let env = db.program_environment(); + assert!(!is_observable_instance(&db, &env, ty)); + assert_eq!(state_value_type(&db, &env, ty), None); + Ok(()) + } + + /// the framework is developed in place, so — unlike `pydantic` (see + /// `role.rs`) — a first-party `basedpython_ui` *is* recognised + #[test] + fn first_party_framework_module_is_recognised() -> anyhow::Result<()> { + let db = first_party_framework("from basedpython_ui.runtime import State\nx = State(1)\n")?; + let ty = last_assigned_type(&db); + let env = db.program_environment(); + let class = ty + .nominal_class(&db, &env) + .and_then(|class| class.static_class_literal(&db)) + .map(|(class, _)| class) + .expect("`State(1)` should infer a nominal instance"); + assert!(class.is_known(&db, KnownClass::BasedpythonUiState)); + assert!(is_observable_instance(&db, &env, ty)); + Ok(()) + } + + #[test] + fn composable_decorator_marks_the_function() -> anyhow::Result<()> { + let db = installed_framework( + "from basedpython_ui import composable\n@composable\ndef view() -> None: ...\n", + )?; + let function = last_function_type(&db); + assert!(function.has_known_decorator(&db, FunctionDecorators::COMPOSABLE)); + assert!(is_composable(&db, function)); + assert_eq!( + function_framework_role(&db, function), + Some(FunctionFrameworkRole::Composable) + ); + Ok(()) + } + + #[test] + fn first_party_composable_decorator_is_recognised() -> anyhow::Result<()> { + let db = first_party_framework( + "from basedpython_ui import composable\n@composable\ndef view() -> None: ...\n", + )?; + let function = last_function_type(&db); + assert!(is_composable(&db, function)); + Ok(()) + } + + #[test] + fn undecorated_function_is_not_composable() -> anyhow::Result<()> { + let db = installed_framework( + "from basedpython_ui import composable\ndef view() -> None: ...\n", + )?; + let function = last_function_type(&db); + assert!(!is_composable(&db, function)); + assert_eq!(function_framework_role(&db, function), None); + Ok(()) + } +} diff --git a/crates/ty_python_semantic/src/types/dedicated/mod.rs b/crates/ty_python_semantic/src/types/dedicated/mod.rs index 53a094daee..daa1f961d4 100644 --- a/crates/ty_python_semantic/src/types/dedicated/mod.rs +++ b/crates/ty_python_semantic/src/types/dedicated/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod basedpython_ui; pub(crate) mod django; pub(super) mod pydantic; pub(super) mod pytest; diff --git a/crates/ty_python_semantic/src/types/dedicated/pytest.rs b/crates/ty_python_semantic/src/types/dedicated/pytest.rs index 90acb3b790..d94a255a53 100644 --- a/crates/ty_python_semantic/src/types/dedicated/pytest.rs +++ b/crates/ty_python_semantic/src/types/dedicated/pytest.rs @@ -31,7 +31,7 @@ use ty_python_core::semantic_index; use crate::Db; use crate::place::known_module_symbol; use crate::types::ProgramEnvironment; -use crate::types::dedicated::role::function_framework_role; +use crate::types::dedicated::role::{FunctionFrameworkRole, function_framework_role}; use crate::types::{ FunctionType, KnownClass, KnownFunction, Type, definition_expression_type, infer_definition_types, @@ -390,7 +390,9 @@ pub(in crate::types) fn injected_parameter_type<'db>( function: FunctionType<'db>, name: &str, ) -> Option> { - function_framework_role(db, function)?; + if !function_framework_role(db, function).is_some_and(FunctionFrameworkRole::is_pytest) { + return None; + } if parametrized_names(db, function).contains(name) { return None; } diff --git a/crates/ty_python_semantic/src/types/dedicated/role.rs b/crates/ty_python_semantic/src/types/dedicated/role.rs index 49beadb9cb..168049b585 100644 --- a/crates/ty_python_semantic/src/types/dedicated/role.rs +++ b/crates/ty_python_semantic/src/types/dedicated/role.rs @@ -14,7 +14,7 @@ use crate::Db; use crate::types::class::CodeGeneratorKind; -use crate::types::dedicated::{django, pydantic, pytest, sqlalchemy}; +use crate::types::dedicated::{basedpython_ui, django, pydantic, pytest, sqlalchemy}; use crate::types::enums::is_enum_class; use crate::types::{ClassLiteral, FunctionType, StaticClassLiteral, Type}; @@ -76,6 +76,22 @@ pub(crate) enum FunctionFrameworkRole { PytestFixture, /// a pytest test — a `test*` function in a collected test file PytestTest, + /// a basedpython-ui composable — a function decorated with the framework's + /// `@composable`, whose body is a composition scope + Composable, +} + +impl FunctionFrameworkRole { + /// whether pytest manages this function — fills its parameters from the + /// fixture registry and reads its `parametrize` markers. The pytest checks + /// gate on this rather than on "has any role", since a composable's + /// parameters are ordinary ones + pub(crate) const fn is_pytest(self) -> bool { + match self { + Self::PytestFixture | Self::PytestTest => true, + Self::Composable => false, + } + } } /// classify `function` against the supported function-level frameworks. @@ -93,6 +109,9 @@ pub fn function_framework_role<'db>( if pytest::is_test_function(db, function) { return Some(FunctionFrameworkRole::PytestTest); } + if basedpython_ui::is_composable(db, function) { + return Some(FunctionFrameworkRole::Composable); + } None } diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs index 3d153400f7..06d00896e6 100644 --- a/crates/ty_python_semantic/src/types/diagnostic.rs +++ b/crates/ty_python_semantic/src/types/diagnostic.rs @@ -185,6 +185,14 @@ pub(crate) fn register_lints(registry: &mut LintRegistryBuilder) { registry.register_lint(&UNRESOLVED_NARROWING_GUARD); registry.register_lint(&NARROWING_GUARD_AS_VALUE); registry.register_lint(&ESCAPING_LOOP_VARIABLE); + registry.register_lint(&MUTABLE_STATE_VALUE); + registry.register_lint(&SILENT_MUTATION); + registry.register_lint(&STATE_WRITE_IN_COMPOSITION); + registry.register_lint(&CONDITIONAL_SLOT); + registry.register_lint(&CONTENT_BLOCK_CONTROL_FLOW); + registry.register_lint(&UNSTABLE_PARAMETER); + registry.register_lint(&COMPOSABLE_OUTSIDE_COMPOSITION); + registry.register_lint(&UNOBSERVABLE_DEPENDENCY); registry.register_lint(&ERASED_CAST_ARGUMENT); registry.register_lint(&UNSOUND_CAST); registry.register_lint(&NON_OVERLAPPING_CAST); @@ -2060,6 +2068,322 @@ declare_lint! { } } +declare_lint! { + /// ## What it does + /// + /// Checks for a value that is not deeply immutable being placed in `basedpython_ui` state: the + /// initial value of `state(...)` / `State(...)`, the elements of `state_list(...)` / `StateList(...)`, + /// the value computed by `derived(...)` / `remember(...)`, a value assigned to a `State`'s `.value`, + /// appended to or inserted into a `StateList`, stored into a `StateDict`, or given to `provide(...)`. + /// + /// ## Why is this bad? + /// + /// A `State` notifies its readers when it is *assigned*. A change made *inside* the held value — + /// `items.append(1)` on a held `list`, a field written on a held plain class — notifies nobody, so the + /// ui keeps showing the old value until something unrelated recomposes it. The runtime refuses such a + /// value with a `TypeError`; this check reports it at the source. + /// + /// A value is deeply immutable when nothing reachable from it can change: the scalars, enum members, a + /// `tuple` or `frozenset` of immutable elements, a `frozen data class` or `NamedTuple` of immutable + /// fields, a type object, a callable, or one of the framework's own observables (`State`, `StateList`, + /// `StateDict`, `Derived`, `Ambient`). + /// + /// ## Examples + /// + /// ```by + /// from basedpython_ui import composable, state, state_list + /// + /// frozen data class Todo: + /// title: str + /// + /// @composable + /// def App(): + /// let items = state([1, 2]) # error: `list[int]` cannot be held in state + /// let names = state((1, 2)) # ok: a tuple of immutables + /// let todos = state_list([Todo("a")]) # ok: an observable list of frozen records + /// ``` + pub(crate) static MUTABLE_STATE_VALUE = { + summary: "detects a mutable value held in basedpython-ui state", + status: LintStatus::stable("0.0.1-alpha.40"), + default_level: Level::Error, + ty_compat: TyCompat::BasedPython, + } +} + +declare_lint! { + /// ## What it does + /// + /// Checks for an in-place mutation written inside a `basedpython_ui` composable — in its body, in a + /// `once` content block written in it, or in a handler block, lambda or nested `def` written in it: a + /// mutating method call (`append`, `extend`, `insert`, `pop`, `remove`, `clear`, `sort`, `reverse`, + /// `update`, `setdefault`, `popitem`, `add`, `discard`, …) on a builtin mutable container, an in-place + /// operator (`+=`, `|=`, …) on one, a subscript store or delete on one, or an attribute store on an + /// instance of a class that is not frozen and not an observable. + /// + /// A container the same body creates itself — bound to a display, a comprehension or a constructor + /// call — is a fresh local, and mutating it is allowed. + /// + /// ## Why is this bad? + /// + /// A composition re-runs when an observable it read is written. A `list` or a plain object is not + /// observable: mutating it in place changes what the ui *should* show without telling the runtime, + /// so the change is not seen until something unrelated recomposes the scope. Mutate a `StateList` / + /// `StateDict`, or rebuild an immutable value and assign it to a `State`, and the change notifies. + /// + /// ## Examples + /// + /// ```by + /// from basedpython_ui import composable, state_list, Button + /// + /// @composable + /// def TodoList(items: list[str]): + /// Button("add"): + /// items.append("x") # error: mutates `list[str]` in place + /// + /// @composable + /// def Observed(): + /// let items = state_list(["a"]) + /// Button("add"): + /// items.append("x") # ok: a `StateList` write notifies its readers + /// ``` + pub(crate) static SILENT_MUTATION = { + summary: "detects an in-place mutation a basedpython-ui composition cannot observe", + status: LintStatus::stable("0.0.1-alpha.40"), + default_level: Level::Error, + ty_compat: TyCompat::BasedPython, + } +} + +declare_lint! { + /// ## What it does + /// + /// Checks for a write to `basedpython_ui` state made while a composition is running: in a composable's + /// body or in a `once` content block written in it, an assignment to a `State`'s `.value` (plain or + /// augmented), a call to `State.set` / `State.update`, or a mutating call, subscript store or delete + /// on a `StateList` / `StateDict`. + /// + /// Writes made from a handler block, a lambda, a nested `def` or an effect block are not in + /// composition: those run later, in response to an event, and are the right place for them. + /// + /// ## Why is this bad? + /// + /// Composition is a pure description of the ui for the current state. A write made while composing + /// invalidates the very scope being composed (or one already composed this frame), which would loop + /// or show a frame that is half old and half new. The runtime raises `CompositionError` before + /// applying such a write; this check reports it at the source. + /// + /// ## Examples + /// + /// ```by + /// from basedpython_ui import composable, state, Button, Text + /// + /// @composable + /// def Counter(): + /// let count = state(0) + /// count.value = 1 # error: written while `Counter` is composing + /// Text(f"{count.value}") + /// Button("+"): + /// count.value += 1 # ok: a handler runs after composition + /// ``` + pub(crate) static STATE_WRITE_IN_COMPOSITION = { + summary: "detects a basedpython-ui state write made while composing", + status: LintStatus::stable("0.0.1-alpha.40"), + default_level: Level::Error, + ty_compat: TyCompat::BasedPython, + } +} + +declare_lint! { + /// ## What it does + /// + /// Checks for a `basedpython_ui` slot — `state`, `state_list`, `state_dict`, `derived`, `remember`, + /// `launched_effect`, `disposable_effect`, `side_effect` — created under a condition in a composable: + /// inside an `if`, `for`, `while`, `try` or `match`, inside a comprehension, or inside a block that is + /// not a `once` content block (a handler block, a lambda, a nested `def`). + /// + /// ## Why is this bad? + /// + /// A slot lives as long as its enclosing composition scope and is identified by its call site, so a + /// conditional slot is created when the condition first holds and disposed — its state lost, its + /// effect cancelled — as soon as it stops holding. That is rarely what the code means: state that + /// should outlive a condition belongs above it, and a slot created from a handler has no scope to live + /// in at all. The runtime keys slots by call site, so this is safe at runtime; the check makes the + /// lifetime visible. + /// + /// ## Examples + /// + /// ```by + /// from basedpython_ui import composable, state, Text + /// + /// @composable + /// def Profile(show: bool): + /// if show: + /// let clicks = state(0) # warning: created and disposed as `show` changes + /// Text(f"{clicks.value}") + /// + /// @composable + /// def Fixed(show: bool): + /// let clicks = state(0) # ok: lives as long as `Fixed` + /// if show: + /// Text(f"{clicks.value}") + /// ``` + pub(crate) static CONDITIONAL_SLOT = { + summary: "detects a basedpython-ui slot created under a condition", + status: LintStatus::stable("0.0.1-alpha.40"), + default_level: Level::Warn, + ty_compat: TyCompat::BasedPython, + } +} + +declare_lint! { + #[doc = include_str!("../../resources/lint_docs/content-block-control-flow.md")] + pub(crate) static CONTENT_BLOCK_CONTROL_FLOW = { + summary: "detects a `return` inside a nested `once` content block", + status: LintStatus::stable("0.0.1-alpha.40"), + default_level: Level::Error, + ty_compat: TyCompat::BasedPython, + } +} + +declare_lint! { + /// ## What it does + /// + /// Checks for a parameter of a `basedpython_ui` composable whose declared type is not *stable*: + /// not deeply immutable, and not a read-only view of immutable elements (`list[out int]`). + /// + /// ## Why is this bad? + /// + /// A composable is skipped on recomposition only when every argument is stable and equal to the last + /// one. A `list`, `dict`, `set` or non-frozen class can be changed behind the composable's back, so the + /// runtime cannot compare it and never skips the scope: the composable re-runs on every recomposition + /// of its parent, however little changed. Prefer an immutable spelling (`tuple[int, ...]`, a + /// `frozen data class`) or an observable (`state_list`, `state_dict`). + /// + /// This is a warning about skipping alone: a mutable parameter that only a handler touches never + /// triggers a re-render, but does not make the composition stale on its own. Reading one while + /// composing is what does that, and is reported as an `unobservable-dependency`. A read-only view + /// (`list[out int]`) is stable for skipping — the runtime compares it structurally at recomposition — + /// but is still unobservable when read, so it is not the spelling to reach for. + /// + /// ## Examples + /// + /// ```by + /// from basedpython_ui import composable, StateList + /// + /// @composable + /// def TodoList(items: list[int]): ... # warning: never skipped + /// + /// @composable + /// def Skippable(items: tuple[int, ...]): ... # ok + /// + /// @composable + /// def Observed(items: StateList[int]): ... # ok: an observable handle + /// ``` + pub(crate) static UNSTABLE_PARAMETER = { + summary: "detects a composable parameter whose type is not stable", + status: LintStatus::stable("0.0.1-alpha.40"), + default_level: Level::Warn, + ty_compat: TyCompat::BasedPython, + } +} + +declare_lint! { + /// ## What it does + /// + /// Checks for a call to a `basedpython_ui` composable (a function decorated `@composable`) or to one + /// of the framework's widget builders (`Text`, `Button`, `Column`, …) from somewhere that is not a + /// composition: a function that is not itself a composable, a handler block, a lambda or a nested + /// `def`. A composable's body, the `once` content blocks and `local` blocks written in it, and the + /// `root` block of `run_app` / `compose_test` are compositions. + /// + /// ## Why is this bad? + /// + /// A composable opens a scope in the composition being built and a builder emits into it; neither has + /// anything to build into outside of one. The runtime raises `CompositionError` at the call; this + /// check reports it at the source. + /// + /// ## Examples + /// + /// ```by + /// from basedpython_ui import composable, run_app, Button, Text + /// + /// @composable + /// def Counter(): ... + /// + /// def helper(): + /// Counter() # error: `helper` is not a composable + /// + /// @composable + /// def App(): + /// Button("x"): + /// Text("clicked") # error: a handler runs after composition + /// + /// def main(): + /// run_app("app"): + /// App() # ok: the root of the composition + /// ``` + pub(crate) static COMPOSABLE_OUTSIDE_COMPOSITION = { + summary: "detects a composable or builder called outside a composition", + status: LintStatus::stable("0.0.1-alpha.40"), + default_level: Level::Error, + ty_compat: TyCompat::BasedPython, + } +} + +declare_lint! { + /// ## What it does + /// + /// Checks for a read, made while a `basedpython_ui` composition runs, of a value it cannot observe: + /// a load of a parameter of the composable (a `context` parameter included), of a module global, or + /// of a local captured from an enclosing function, whose type is neither deeply immutable nor one of + /// the framework's observables (`State`, `StateList`, `StateDict`, `Derived`, `Ambient`). + /// + /// What runs while composing is the composable's body, the `once` content blocks and `local` blocks + /// written in it, and the lambda given to `derived(...)` / `remember(...)`. A handler block, a lambda, + /// a nested `def` or an effect block runs later, so a read there is not a dependency of the + /// composition. A name the composition binds itself — a local of the body or of a block, a `for` + /// target, a comprehension variable — is this run's own value and is not reported, whatever its + /// type; a `dynamic` value is exempt, as everywhere. A read-only view (`list[out str]`) is reported + /// like a plain `list`: it restricts this reader, not the other holders of the list. + /// + /// ## Why is this bad? + /// + /// A mutation of non-observable data is never a trigger: an immutable value cannot change, an + /// observable notifies its readers when it does, and a mutable value changes without telling anyone. + /// A composition that reads a mutable parameter or global therefore shows a stale ui after any change + /// to it — wherever that change is made: another module, a `.py` caller, a `dynamic` value, a + /// callback. `silent-mutation` reports the writes it can see; this check is what makes the guarantee + /// general, by keeping a composition from depending on such a value in the first place. + /// + /// Hold the value in state (`state_list`, `state_dict`), pass an immutable value (a `tuple`, a + /// `frozen data class`), or read it only in a handler. + /// + /// ## Examples + /// + /// ```by + /// from basedpython_ui import composable, state_list, Text + /// + /// @composable + /// def Names(items: list[str]): + /// Text(str(len(items))) # error: read while `Names` composes, but nothing observes it + /// + /// @composable + /// def Frozen(items: tuple[str, ...]): + /// Text(str(len(items))) # ok: a tuple cannot change + /// + /// @composable + /// def Held(): + /// let items = state_list(["a"]) + /// Text(str(len(items))) # ok: a `StateList` notifies its readers + /// ``` + pub(crate) static UNOBSERVABLE_DEPENDENCY = { + summary: "detects a basedpython-ui composition reading a value it cannot observe", + status: LintStatus::stable("0.0.1-alpha.40"), + default_level: Level::Error, + ty_compat: TyCompat::BasedPython, + } +} + declare_lint! { /// ## What it does /// Checks for a basedpython `cast` / `cast?` whose target type carries type diff --git a/crates/ty_python_semantic/src/types/function.rs b/crates/ty_python_semantic/src/types/function.rs index 8ba0f0c80d..49bff5c7ba 100644 --- a/crates/ty_python_semantic/src/types/function.rs +++ b/crates/ty_python_semantic/src/types/function.rs @@ -209,6 +209,15 @@ bitflags! { /// basedpython: `@must_use_return_value` — a call to this function must /// have its result used, even where the enclosing class says otherwise const MUST_USE_RETURN_VALUE = 1 << 12; + /// basedpython-ui: the function is decorated with the framework's + /// `@composable` — its body is a composition scope. The decorator is + /// identity-typed (`[F](fn: F) -> F`), so the function keeps its literal + /// type and this flag is the only trace the decorator leaves + const COMPOSABLE = 1 << 13; + /// basedpython-ui: the function is decorated with the framework's + /// `@builder` — a widget builder, which emits into the composition being + /// built and so, like a composable, can only be called while composing + const UI_BUILDER = 1 << 14; } } @@ -230,6 +239,8 @@ impl FunctionDecorators { Some(KnownFunction::MustUseReturnValue) => { FunctionDecorators::MUST_USE_RETURN_VALUE } + Some(KnownFunction::BasedpythonUiComposable) => FunctionDecorators::COMPOSABLE, + Some(KnownFunction::BasedpythonUiBuilder) => FunctionDecorators::UI_BUILDER, _ => FunctionDecorators::empty(), }, Type::ClassLiteral(class) => match class.known(db) { @@ -3246,6 +3257,49 @@ pub enum KnownFunction { #[strum(serialize = "yield_fixture")] PytestYieldFixture, + /// `basedpython_ui.runtime.composable` — the framework's `@composable` + /// decorator, which marks a function as a composition scope + #[strum(serialize = "composable")] + BasedpythonUiComposable, + /// `basedpython_ui.runtime.builder` — the framework's `@builder` decorator, + /// which marks a widget builder: a function that emits into the composition + /// being built + #[strum(serialize = "builder")] + BasedpythonUiBuilder, + /// `basedpython_ui.runtime.state` — remembers a `State[T]` cell for the + /// enclosing composition scope + #[strum(serialize = "state")] + BasedpythonUiState, + /// `basedpython_ui.runtime.state_list` + #[strum(serialize = "state_list")] + BasedpythonUiStateList, + /// `basedpython_ui.runtime.state_dict` + #[strum(serialize = "state_dict")] + BasedpythonUiStateDict, + /// `basedpython_ui.runtime.derived` — a memoised `Derived[T]` over state + #[strum(serialize = "derived")] + BasedpythonUiDerived, + /// `basedpython_ui.runtime.remember` — a value computed once per scope + #[strum(serialize = "remember")] + BasedpythonUiRemember, + /// `basedpython_ui.runtime.launched_effect` + #[strum(serialize = "launched_effect")] + BasedpythonUiLaunchedEffect, + /// `basedpython_ui.runtime.disposable_effect` + #[strum(serialize = "disposable_effect")] + BasedpythonUiDisposableEffect, + /// `basedpython_ui.runtime.side_effect` + #[strum(serialize = "side_effect")] + BasedpythonUiSideEffect, + /// `basedpython_ui.runtime.provide` — overrides an `Ambient[T]` for a subtree + #[strum(serialize = "provide")] + BasedpythonUiProvide, + /// `basedpython_ui.app.run_app` — composes its `root` block into a window + #[strum(serialize = "run_app")] + BasedpythonUiRunApp, + /// `basedpython_ui.app.compose_test` — composes its `root` block headlessly + #[strum(serialize = "compose_test")] + BasedpythonUiComposeTest, /// `functools.total_ordering` TotalOrdering, @@ -3270,6 +3324,8 @@ pub enum KnownFunction { IsDisjointFrom, /// `ty_extensions._internal.is_singleton` IsSingleton, + /// `ty_extensions._internal.is_deeply_immutable` (basedpython) + IsDeeplyImmutable, /// `ty_extensions._internal.generic_context` GenericContext, /// `ty_extensions._internal.into_callable` @@ -3372,6 +3428,20 @@ impl KnownFunction { matches!(module, KnownModule::Dataclasses) } Self::PydanticField => matches!(module, KnownModule::PydanticFields), + Self::BasedpythonUiComposable + | Self::BasedpythonUiBuilder + | Self::BasedpythonUiState + | Self::BasedpythonUiStateList + | Self::BasedpythonUiStateDict + | Self::BasedpythonUiDerived + | Self::BasedpythonUiRemember + | Self::BasedpythonUiLaunchedEffect + | Self::BasedpythonUiDisposableEffect + | Self::BasedpythonUiSideEffect + | Self::BasedpythonUiProvide => matches!(module, KnownModule::BasedpythonUiRuntime), + Self::BasedpythonUiRunApp | Self::BasedpythonUiComposeTest => { + matches!(module, KnownModule::BasedpythonUiApp) + } Self::PydanticFieldValidator => { matches!(module, KnownModule::PydanticFunctionalValidators) } @@ -3388,6 +3458,7 @@ impl KnownFunction { | Self::IsDisjointFrom | Self::IsEquivalentTo | Self::IsSingleton + | Self::IsDeeplyImmutable | Self::IsSubtypeOf | Self::GenericContext | Self::IntoCallable @@ -4078,6 +4149,20 @@ pub(crate) mod tests { KnownFunction::PytestFixture | KnownFunction::PytestYieldFixture => { KnownModule::PytestFixtures } + KnownFunction::BasedpythonUiComposable + | KnownFunction::BasedpythonUiBuilder + | KnownFunction::BasedpythonUiState + | KnownFunction::BasedpythonUiStateList + | KnownFunction::BasedpythonUiStateDict + | KnownFunction::BasedpythonUiDerived + | KnownFunction::BasedpythonUiRemember + | KnownFunction::BasedpythonUiLaunchedEffect + | KnownFunction::BasedpythonUiDisposableEffect + | KnownFunction::BasedpythonUiSideEffect + | KnownFunction::BasedpythonUiProvide => KnownModule::BasedpythonUiRuntime, + KnownFunction::BasedpythonUiRunApp | KnownFunction::BasedpythonUiComposeTest => { + KnownModule::BasedpythonUiApp + } KnownFunction::GetattrStatic => KnownModule::Inspect, @@ -4102,6 +4187,7 @@ pub(crate) mod tests { | KnownFunction::MustUseReturnValue => KnownModule::TyExtensions, KnownFunction::IsSingleton + | KnownFunction::IsDeeplyImmutable | KnownFunction::IsSubtypeOf | KnownFunction::GenericContext | KnownFunction::IntoCallable diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index 26dfe720ea..29bf43ea5b 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -60,6 +60,8 @@ pub use stub_mapping::map_stub_definition; pub use unreachable_code::{UnreachableKind, UnreachableRange, unreachable_ranges}; pub use unused_binding_support::{UnusedBinding, unused_bindings}; +pub use crate::types::state_invalidations::WriteSite; + /// Get the primary definition kind for a name expression within a specific file. /// Returns the first definition kind that is reachable for this name in its scope. /// This is useful for IDE features like semantic tokens. @@ -2815,6 +2817,176 @@ pub fn inferred_raises<'db>( (!raised.is_never()).then_some(raised) } +/// basedpython-ui: one observable a function reads while composing, as the +/// IDE shows it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StateRead { + /// the place as it is written: the root name and the attribute path read + /// from it (`count`, `self.model.count`) + pub name: String, + /// where the root name is declared + pub declaration: FileRange, +} + +/// basedpython-ui: the observables a function or a `derived` computation +/// reads while composing. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct InferredStateReads { + /// the reads, in declaration order of their roots + pub reads: Vec, + /// whether a callee could not be followed, so the set may be missing + /// something — shown as `…` + pub opaque: bool, +} + +impl InferredStateReads { + fn from_reads(db: &dyn Db, reads: &crate::types::state_reads::StateReads<'_>) -> Self { + Self { + reads: reads + .places + .iter() + .map(|place| StateRead { + name: place.display_name(), + declaration: place.declaration(db), + }) + .collect(), + opaque: reads.opaque, + } + } +} + +/// basedpython-ui: the observables a call to `function` reads while +/// composing, when it reads any. +/// +/// A set with nothing in it but the opaque marker is shown for a composable +/// alone: that its dependencies cannot be seen is worth saying of a +/// composition, while a plain `def` that reaches a `dynamic` value — every +/// `main` that starts an app does — would only be told what it already knows. +/// +/// Resolving the set follows every call into its callee, so a body whose own +/// effects are empty is answered without touching the call graph — which keeps +/// the common case, a `def` that composes nothing, cheap. +pub fn inferred_state_reads<'db>( + db: &'db dyn Db, + function: Type<'db>, +) -> Option { + let Type::FunctionLiteral(function) = function else { + return None; + }; + let literal = function.literal(db); + if literal + .iter_overloads_and_implementation(db) + .all(|overload| crate::types::state_reads::body_state_read_effects(db, overload).is_empty()) + { + return None; + } + + let reads = crate::types::state_reads::function_state_reads(db, literal); + let shown = !reads.places.is_empty() + || (reads.opaque && crate::types::dedicated::basedpython_ui::is_composable(db, function)); + shown.then(|| InferredStateReads::from_reads(db, &reads)) +} + +/// basedpython-ui: what the computation of a `derived(lambda: ...)` or +/// `remember(lambda: ...)` call depends on — the observables the lambda's body +/// reads, its callees followed. `None` for any other call, for a computation +/// that is not written as a lambda, and when nothing is read. +pub fn inferred_derived_dependencies( + model: &SemanticModel<'_>, + call: &ast::ExprCall, +) -> Option { + let db = model.db(); + let callee = call.func.inferred_type(model)?; + if !matches!( + callee + .as_function_literal() + .and_then(|function| function.known(db)), + Some(KnownFunction::BasedpythonUiDerived | KnownFunction::BasedpythonUiRemember) + ) { + return None; + } + let ast::Expr::Lambda(lambda) = call.arguments.find_argument_value("compute", 0)? else { + return None; + }; + + let program_file = model.program_file(); + let index = semantic_index(db, program_file); + let module = parsed_module(db, program_file.python_file(db)).load(db); + let reads = + crate::types::state_reads::lambda_state_reads(db, program_file, index, &module, lambda); + (!reads.is_empty()).then(|| InferredStateReads::from_reads(db, &reads)) +} + +/// basedpython-ui: the composition scopes a state write invalidates, as the +/// IDE shows them. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct InferredInvalidations { + /// the scopes, in declaration order with the write's own file first, each + /// named as the runtime's trace names it — the composable's name, `root`, + /// the name a `derived` is bound to — and navigable to its declaration + pub scopes: Vec, + /// whether a reader may have been missed — a caller or a reader of a + /// module-level slot in another file, a callee that cannot be followed — + /// shown as `…` + pub opaque: bool, + /// where the last write of the site is spelled, for placing the hint on + /// its line + pub anchor: TextRange, +} + +/// basedpython-ui: the composition scopes the observable writes of `site` +/// invalidate — the composables, root blocks and `derived` computations whose +/// composition depends on what is written. `None` when the site writes no +/// observable, and for a write made while composing, which is a diagnostic +/// rather than something to trace. +/// +/// An empty set is an answer: a write nobody observes is worth seeing. +pub fn inferred_invalidations( + model: &SemanticModel<'_>, + site: WriteSite<'_>, +) -> Option { + let db = model.db(); + let invalidations = + crate::types::state_invalidations::site_invalidations(db, model.program_file(), site)?; + Some(InferredInvalidations { + scopes: invalidations + .scopes + .iter() + .map(|scope| StateRead { + name: scope.name.to_string(), + declaration: FileRange::new(scope.file(db), scope.declaration), + }) + .collect(), + opaque: invalidations.opaque, + anchor: invalidations.anchor, + }) +} + +/// basedpython-ui: whether a composable parameter of type `ty` is *stable* — +/// deeply immutable, or a read-only view of immutable elements — so that two +/// equal arguments let the runtime skip the composable. `None` when nothing is +/// known of the type, which says nothing about the parameter. +pub fn parameter_stability<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, +) -> Option { + if ty.is_unknown() { + return None; + } + Some(crate::types::immutability::is_stable_parameter_type( + db, env, ty, + )) +} + +/// basedpython-ui: whether `function` is decorated with the framework's +/// `@composable`. +pub fn is_composable_function<'db>(db: &'db dyn Db, function: Type<'db>) -> bool { + function.as_function_literal().is_some_and(|function| { + crate::types::dedicated::basedpython_ui::is_composable(db, function) + }) +} + /// basedpython: the variance ty infers for the type parameter named `name` of /// the generic `owner`, in its surface spelling (`out` / `in` / `in out`). /// @@ -3094,10 +3266,8 @@ pub fn trailing_lambda_implicit_parameters<'db>( function: &ast::StmtFunctionDef, ) -> Vec<(&'static str, Option>)> { let db = model.db(); - let Some(callee) = function - .trailing_lambda_callee() - .and_then(|callee| callee.inferred_type(model)) - else { + let index = semantic_index(db, db.program_file(model.file())); + let Some(callee) = crate::types::trailing_lambda::block_callee(db, index, function) else { return Vec::new(); }; // the block has an `it` parameter whatever its callback does, because the lambda the diff --git a/crates/ty_python_semantic/src/types/immutability.rs b/crates/ty_python_semantic/src/types/immutability.rs new file mode 100644 index 0000000000..f28fc1bd46 --- /dev/null +++ b/crates/ty_python_semantic/src/types/immutability.rs @@ -0,0 +1,305 @@ +//! basedpython-ui: deep immutability of a type — the checker's half of the +//! framework's *stability* notion (`docs/design.md` §4.1) +//! +//! A value is *deeply immutable* when nothing reachable from it can change +//! after it is created. The framework needs exactly that property from a value +//! held in state: a `State[T]` notifies its readers when it is *assigned*, so a +//! change made *inside* the value — `items.append(1)` on a held `list` — is one +//! no reader can observe. The same property is what lets a composable be +//! skipped: two stable arguments that compare equal describe the same ui. +//! +//! The predicate answers `true` for: +//! +//! - the scalars `int`, `float`, `bool`, `str`, `bytes`, `None`, `complex`, +//! `range` (and every literal of them) +//! - enum members, and instances of an enum class — a basedpython `enum class` +//! included: its unit variants are members, its payload variants frozen +//! dataclasses, checked field by field +//! - a `tuple` or `frozenset` whose elements are deeply immutable +//! - a frozen dataclass (a basedpython `frozen data class` included) or a +//! `NamedTuple` whose fields are deeply immutable +//! - type objects, callables, and the framework's observables (`State`, +//! `StateList`, `StateDict`, `Derived`, `Ambient`): identity-stable handles +//! whose mutations notify +//! - a union when every member is, an intersection when any positive member is +//! - a type variable when its bound (or every constraint) is. A type variable +//! with no bound stands for whatever the caller passes, which is checked +//! where the call solves it — so the generic body itself is not blamed +//! - the gradual types: nothing is known, so nothing is reported +//! +//! Everything else is mutable: `list`, `dict`, `set`, `bytearray`, `deque`, a +//! non-frozen class, a protocol, `object`. The runtime mirrors this predicate +//! in `basedpython_ui.runtime.is_stable_type`, as the defence for `.py` callers +//! and `dynamic` values the checker cannot see. + +use ruff_python_ast::helpers::UseSiteVariance; + +use crate::types::class::{ClassLiteral, CodeGeneratorKind}; +use crate::types::dedicated::basedpython_ui::{is_observable_instance, underlying}; +use crate::types::enums::is_enum_class; +use crate::types::instance::NominalInstanceType; +use crate::types::{KnownClass, ProgramEnvironment, Type, TypeVarBoundOrConstraints}; +use crate::{Db, Program}; + +/// whether no value of `ty` can change after it is created — see the module +/// documentation for exactly what counts +pub(crate) fn is_deeply_immutable<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, +) -> bool { + is_deeply_immutable_impl(db, ty, env.program(db)) +} + +// a frozen dataclass may hold a field of its own type; the recursion bottoms +// out on the identity of "all fields immutable" +#[salsa::tracked( + returns(copy), + cycle_initial = |_, _, _, _| true, + heap_size = ruff_memory_usage::heap_size +)] +fn is_deeply_immutable_impl<'db>(db: &'db dyn Db, ty: Type<'db>, program: Program<'db>) -> bool { + let env = &ProgramEnvironment::from_program(program); + let immutable = |ty: Type<'db>| is_deeply_immutable(db, env, ty); + match ty { + // nothing is known about a gradual type, so nothing is reported + Type::Dynamic(_) | Type::Divergent(_) | Type::Never => true, + + // a callable is stable by identity + Type::FunctionLiteral(_) + | Type::BoundMethod(_) + | Type::KnownBoundMethod(_) + | Type::WrapperDescriptor(_) + | Type::DataclassDecorator(_) + | Type::DataclassTransformer(_) + | Type::Callable(_) => true, + + // a type object, and the objects the typing machinery builds. a slot + // descriptor belongs here with a property: both are class-level + // descriptor objects that never change, and neither holds the instance + // whose attribute they mediate + Type::ClassLiteral(_) + | Type::GenericAlias(_) + | Type::SubclassOf(_) + | Type::TypeForm(_) + | Type::SpecialForm(_) + | Type::KnownInstance(_) + | Type::PropertyInstance(_) + | Type::SlotDescriptor(_) => true, + + // literals, enum members, and the `bool` narrowing types + Type::LiteralValue(_) | Type::EnumComplement(_) | Type::TypeIs(_) | Type::TypeGuard(_) => { + true + } + + // a module's attributes are writable; a `super()` proxy, a truthiness + // set and a protocol say nothing about the object behind them; a + // `TypedDict` is a `dict` + Type::ModuleLiteral(_) + | Type::BoundSuper(_) + | Type::AlwaysTruthy + | Type::AlwaysFalsy + | Type::ProtocolInstance(_) + | Type::TypedDict(_) => false, + + Type::Union(union) => union.elements(db).iter().copied().all(immutable), + Type::UnsafeUnion(union) => union.elements(db).iter().copied().all(immutable), + Type::Intersection(intersection) => { + intersection.positive(db).iter().copied().any(immutable) + } + + Type::TypeVar(bound_typevar) => { + match bound_typevar.typevar(db).bound_or_constraints(db, env) { + None => true, + Some(TypeVarBoundOrConstraints::UpperBound(bound)) => immutable(bound), + Some(TypeVarBoundOrConstraints::Constraints(constraints)) => { + constraints.elements(db).iter().copied().all(immutable) + } + } + } + + Type::Overlapping(overlapping) => immutable(overlapping.value_type(db, env)), + Type::Restricted(restricted) => immutable(restricted.value_type(db)), + Type::Deferred(deferred) => immutable(deferred.reduced(db, env)), + Type::TypeAlias(alias) => immutable(alias.value_type(db)), + Type::NewTypeInstance(newtype) => immutable(newtype.concrete_base_type(db)), + + Type::NominalInstance(instance) => instance_is_deeply_immutable(db, env, instance), + } +} + +/// the nominal-instance half of [`is_deeply_immutable`]: the class decides, +/// and a container or record is only as immutable as what it holds +fn instance_is_deeply_immutable<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + instance: NominalInstanceType<'db>, +) -> bool { + let immutable = |ty: Type<'db>| is_deeply_immutable(db, env, ty); + + if let Some(tuple) = instance.tuple_spec(db, env) { + return tuple.iter_element_types(db).all(immutable); + } + + let class = instance.class(db, env); + let Some((literal, specialization)) = class.static_class_literal(db) else { + return false; + }; + + match literal.known(db) { + Some( + KnownClass::Int + | KnownClass::Float + | KnownClass::Bool + | KnownClass::Str + | KnownClass::Bytes + | KnownClass::NoneType + | KnownClass::Complex + | KnownClass::Range + | KnownClass::Type + | KnownClass::EllipsisType + | KnownClass::NotImplementedType, + ) => return true, + Some(KnownClass::FrozenSet) => { + return specialization.is_none_or(|specialization| { + specialization.types(db).iter().copied().all(immutable) + }); + } + _ => {} + } + + if is_observable_instance(db, env, Type::NominalInstance(instance)) { + return true; + } + + if is_enum_class(db, Type::ClassLiteral(ClassLiteral::Static(literal))) { + return true; + } + + // a record is immutable when it cannot be written and holds only immutable + // fields. a based enum's payload variant is a frozen dataclass; its unit + // variants have no storage at all + let Some(field_policy) = CodeGeneratorKind::from_class(db, ClassLiteral::Static(literal)) + else { + return literal.is_enum_variant(db); + }; + let frozen = match field_policy { + CodeGeneratorKind::NamedTuple => true, + CodeGeneratorKind::DataclassLike(_) | CodeGeneratorKind::Pydantic(_) => { + literal.is_frozen_dataclass(db) == Some(true) + } + _ => false, + }; + frozen + && literal + .fields(db, specialization, field_policy) + .values() + .all(|field| immutable(field.declared_ty)) +} + +/// whether `ty` is an instance of one of the builtin mutable containers — +/// `list`, `dict`, `set`, `bytearray`, `deque`, `defaultdict` — or of a subclass +/// of one, whose mutating methods change it in place without anything +/// observing the change +pub(crate) fn is_builtin_mutable_container<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, +) -> bool { + let Type::NominalInstance(instance) = underlying(db, ty) else { + return false; + }; + let Some((literal, specialization)) = instance.class(db, env).static_class_literal(db) else { + return false; + }; + literal + .iter_mro(db, specialization) + .filter_map(crate::types::ClassBase::into_class) + .any(|base| { + matches!( + base.known(db), + Some( + KnownClass::List + | KnownClass::Dict + | KnownClass::Set + | KnownClass::Bytearray + | KnownClass::Deque + | KnownClass::DefaultDict + ) + ) + }) +} + +/// whether `ty` is a builtin mutable container seen through a use-site `out` +/// projection (`list[out int]`): a read-only view, through which the checker +/// already rejects every write +pub(crate) fn is_write_projected<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, +) -> bool { + let Type::NominalInstance(instance) = underlying(db, ty) else { + return false; + }; + let Some((_, Some(specialization))) = instance.class(db, env).static_class_literal(db) else { + return false; + }; + specialization + .projections(db) + .contains(&Some(UseSiteVariance::Out)) +} + +/// whether `ty` is a builtin mutable container whose every type argument is +/// projected `out` and deeply immutable — `list[out int]`, `dict[out str, out +/// int]`: a read-only view of immutable elements, the recommended spelling +/// for a composable that must accept a container it will never mutate +fn is_read_only_view<'db>(db: &'db dyn Db, env: &ProgramEnvironment<'db>, ty: Type<'db>) -> bool { + if !is_builtin_mutable_container(db, env, ty) { + return false; + } + let Type::NominalInstance(instance) = underlying(db, ty) else { + return false; + }; + let Some((_, Some(specialization))) = instance.class(db, env).static_class_literal(db) else { + return false; + }; + let projections = specialization.projections(db); + !projections.is_empty() + && projections + .iter() + .all(|projection| *projection == Some(UseSiteVariance::Out)) + && specialization + .types(db) + .iter() + .all(|argument| is_deeply_immutable(db, env, *argument)) +} + +/// whether a composable parameter of type `ty` is *stable*: deeply immutable, +/// or a read-only view of immutable elements (`list[out int]`), which the +/// composable cannot mutate and so may accept in place of a `tuple`. A union +/// is stable when every member is. +/// +/// Stability is the *skipping* question, and a read-only view answers it: at +/// recomposition the runtime compares the argument structurally, and at that +/// moment the comparison is correct — a `list[out int]` that compares equal +/// to the last one describes the same ui. *Observability* is a different +/// question, asked of what a composition reads: a view restricts only this +/// reader, not the other holders of the list, so a write made through one of +/// them between two compositions notifies nobody. That is why a read of such +/// a parameter while composing is still an `unobservable-dependency`, decided +/// by [`is_deeply_immutable`] alone +pub(crate) fn is_stable_parameter_type<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + ty: Type<'db>, +) -> bool { + // a use-site restriction or alias around the type says nothing about + // stability: `final list[out int]` is the view inside it + match underlying(db, ty) { + Type::Union(union) => union + .elements(db) + .iter() + .all(|element| is_stable_parameter_type(db, env, *element)), + ty => is_deeply_immutable(db, env, ty) || is_read_only_view(db, env, ty), + } +} diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index e9e6d3bbb8..efc4a1c48d 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -1361,6 +1361,13 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ); if self.db().should_check_file(self.file()) { + // basedpython-ui: the composition model's checks run over every + // scope once its types are known, reading this in-progress + // inference rather than re-entering it as a query + crate::types::composition::check_scope(&self.context, self.index, |expr| { + self.try_expression_type(expr) + }); + let mut seen_overloaded_places = FxHashSet::default(); let mut seen_public_functions = FxHashSet::default(); @@ -1684,6 +1691,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { /// so argument-index lookups can't reach for the synthetic argument, /// which has no AST node. fn infer_trailing_lambda_marker(&mut self, function: &ast::StmtFunctionDef) { + let db = self.db(); let env = self.program_environment(); let Some(signature_callee) = function.trailing_lambda_callee() else { return; @@ -1705,60 +1713,71 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { // result without a cycle through the decorators region let callee_ty = self.infer_standalone_expression(signature_callee, TypeContext::default()); - let mut items: Vec<(Argument<'_>, Option>)> = Vec::new(); let marker_call = match called { ast::Expr::Call(call) if std::ptr::eq(signature_callee, call.func.as_ref()) => { Some(call) } _ => None, }; - if let Some(call) = marker_call { - for arg_or_keyword in call.arguments.iter_source_order() { - let item = match arg_or_keyword { - ast::ArgOrKeyword::Arg(argument) => match argument { - ast::Expr::Starred(ast::ExprStarred { value, .. }) => { - let ty = self.infer_expression(value, TypeContext::default()); - self.store_expression_type(argument, ty); - (Argument::Variadic, Some(ty)) - } - _ => ( - Argument::Positional, - Some(self.infer_expression(argument, TypeContext::default())), - ), - }, - ast::ArgOrKeyword::Keyword(ast::Keyword { arg, value, .. }) => { - let ty = self.infer_expression(value, TypeContext::default()); - match arg { - Some(name) => (Argument::Keyword(&name.id), Some(ty)), - None => (Argument::Keywords, Some(ty)), - } - } - }; - items.push(item); - } - } - let keyword = trailing_lambda_keyword(self.db(), callee_ty); + // the explicit arguments take the ordinary call path — matched to + // parameters, inferred against the parameter they fill, then checked — + // so a block-carrying call resolves `context` arguments and literal + // conversions exactly like the same call without a block. the block is + // appended as a synthetic argument of a gradual callable type: its real + // signature is inferred in its own scope, which reads the callee back + // from here — a cycle this shape avoids + let keyword = trailing_lambda_keyword(db, callee_ty); + let mut call_arguments = match marker_call { + Some(call) => self.prepare_call_arguments(&call.arguments), + None => CallArguments::none(), + }; let block_ty = Type::single_callable( - self.db(), + db, Signature::new(Parameters::gradual_form(), Type::unknown()), ); - items.push(( + call_arguments.push( match &keyword { Some(name) => Argument::Keyword(name), None => Argument::Positional, }, - Some(block_ty), - )); - let call_arguments: CallArguments<'_, 'db> = items.into_iter().collect(); + block_ty, + ); - let return_ty = match callee_ty.try_call(self.db(), env, &call_arguments) { - Ok(bindings) => bindings.return_type(self.db(), env), - Err(error) => { - error.1.report_diagnostics(&self.context, decorator.into()); - error.return_type(self.db(), env) - } + let mut bindings = + self.bindings_for_call(callee_ty) + .match_parameters(db, env, &call_arguments); + // basedpython: fill unmatched `context` parameters from the `context` + // declarations visible at the block, gated like an ordinary call to the + // callables the transpiler can inject for + if matches!(callee_ty, Type::FunctionLiteral(_) | Type::BoundMethod(_)) { + bindings.resolve_context_arguments(db, env, self.scope(), called.range().start()); + } + let ast_arguments = match marker_call { + Some(call) => ArgumentsIter::from_ast(&call.arguments), + None => ArgumentsIter::synthesized(&[]), }; + // the written arguments are standalone expressions (see the semantic + // index builder), shared with the block's own `it` typing + let bindings_result = self.infer_and_check_argument_types( + ast_arguments, + &mut call_arguments, + &mut |builder, (_, expr, tcx)| builder.infer_maybe_standalone_expression(expr, tcx), + &mut bindings, + TypeContext::default(), + ); + // a written argument's diagnostic is anchored on that argument — which + // is also what lets a literal it holds be repaired by a conversion — and + // one about the synthetic block, which has no node, on the whole call + if bindings_result.is_err() { + let node: ast::AnyNodeRef<'_> = match marker_call { + Some(call) => call.into(), + None => decorator.into(), + }; + bindings.report_diagnostics(&self.context, node); + } + + let return_ty = bindings.return_type(db, env); if marker_call.is_some() { self.store_expression_type(called, return_ty); } diff --git a/crates/ty_python_semantic/src/types/infer/builder/function.rs b/crates/ty_python_semantic/src/types/infer/builder/function.rs index 88e8cec86f..0a6b8d9bbe 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/function.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/function.rs @@ -6,7 +6,7 @@ use crate::{ SubclassOfType, Type, TypeContext, TypeVarKind, UnionType, class::ClassLiteral, constraints::ConstraintSetBuilder, - dedicated::pytest, + dedicated::{pytest, role::FunctionFrameworkRole}, diagnostic::{ ABSTRACT_AND_FINAL_METHOD, FINAL_ON_NON_METHOD, INEFFECTIVE_PRIVATE, INVALID_FIXTURE_TYPE, INVALID_PARAMETER_DEFAULT, INVALID_PARAMETRIZE, @@ -35,13 +35,14 @@ use crate::{ infer_function_default_types, infer_statement_types, nearest_enclosing_function, original_class_type, }, - infer_definition_types, infer_expression_types, + infer_definition_types, inferred_signature::{can_implicitly_return_none, return_type_from_body}, lifetimes::InheritedBorrow, relation::TypeRelation, signatures::{ReturnCallableTypeVarScope, function_signature_expression_type}, trailing_lambda::{ - UnbindableParameters, trailing_lambda_it_borrow, trailing_lambda_it_type, + BlockCallee, UnbindableParameters, block_callee, trailing_lambda_it_borrow, + trailing_lambda_it_type, }, tuple::{TupleSpecBuilder, TupleType}, typed_dict::extract_unpacked_typed_dict_keys_from_kwargs_annotation, @@ -669,7 +670,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let Some(function) = self.current_function_type() else { return; }; - if function_framework_role(db, function).is_none() { + if !function_framework_role(db, function).is_some_and(FunctionFrameworkRole::is_pytest) { return; } @@ -1964,15 +1965,16 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { function.is_trailing_lambda.then_some(function) } - /// basedpython: the type of the expression a trailing lambda block is - /// attached to, read from its standalone-expression inference (registered by - /// the semantic index builder — independent of the enclosing definition's - /// inference, so no cycle) - fn trailing_lambda_callee_type(&self, function: &ast::StmtFunctionDef) -> Option> { - let callee = function.trailing_lambda_callee()?; - let expression = self.index.try_expression(callee)?; - infer_expression_types(self.db(), expression, TypeContext::default()) - .try_expression_type(callee) + /// basedpython: the callee a trailing lambda block is attached to, with what + /// the block's call solves for it — read from the standalone-expression + /// inferences the semantic index builder registers for the callee and the + /// written arguments (independent of the enclosing definition's inference, + /// so no cycle) + fn trailing_lambda_block_callee( + &self, + function: &ast::StmtFunctionDef, + ) -> Option> { + block_callee(self.db(), self.index, function) } /// basedpython: the type of a trailing lambda's implicit `it` parameter. @@ -1982,7 +1984,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { &self, function: &ast::StmtFunctionDef, ) -> Option> { - trailing_lambda_it_type(self.db(), self.trailing_lambda_callee_type(function)?) + trailing_lambda_it_type(self.db(), self.trailing_lambda_block_callee(function)?) } /// basedpython: the borrow a trailing-lambda block's implicit `it` inherits @@ -2002,10 +2004,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { } let db = self.db(); let callee = function.trailing_lambda_callee()?; - let expression = self.index.try_expression(callee)?; - let callee_ty = infer_expression_types(db, expression, TypeContext::default()) - .try_expression_type(callee)?; - let borrow = trailing_lambda_it_borrow(db, callee_ty); + let borrow = trailing_lambda_it_borrow(db, self.trailing_lambda_block_callee(function)?); if !borrow.is_borrow() { return None; } @@ -2025,9 +2024,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { /// parameter `once` — the block then runs exactly once (`with`-like). Anything /// unresolvable is treated as not-`once` (the restricted default). fn trailing_lambda_callee_is_once(&self, function: &ast::StmtFunctionDef) -> bool { - self.trailing_lambda_callee_type(function) - .is_some_and(|callee_ty| { - crate::types::trailing_lambda::callee_callback_is_once(self.db(), callee_ty) + self.trailing_lambda_block_callee(function) + .is_some_and(|callee| { + crate::types::trailing_lambda::callee_callback_is_once(self.db(), callee.ty) }) } @@ -2060,7 +2059,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let Some(callee) = function.trailing_lambda_callee() else { return; }; - let Some(callee_ty) = self.trailing_lambda_callee_type(function) else { + let Some(callee_ty) = self.trailing_lambda_block_callee(function) else { return; }; let Some(unbindable) = @@ -2096,7 +2095,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let Some(callee) = function.trailing_lambda_callee() else { return; }; - let Some(callee_ty) = self.trailing_lambda_callee_type(function) else { + let Some(callee_ty) = self.trailing_lambda_block_callee(function) else { return; }; let Some(return_ty) = diff --git a/crates/ty_python_semantic/src/types/lifetimes.rs b/crates/ty_python_semantic/src/types/lifetimes.rs index 9490f904a0..09d13e6a19 100644 --- a/crates/ty_python_semantic/src/types/lifetimes.rs +++ b/crates/ty_python_semantic/src/types/lifetimes.rs @@ -30,17 +30,17 @@ use ruff_python_ast::visitor::{Visitor, walk_expr}; use ruff_python_ast::{self as ast, Expr, ExprContext, ExprName, ParameterBorrow, Stmt}; use ruff_text_size::{Ranged, TextRange}; use rustc_hash::{FxHashMap, FxHashSet}; -use ty_python_core::SemanticIndex; use ty_python_core::scope::{FileScopeId, NodeWithScopeKind}; +use ty_python_core::{SemanticIndex, semantic_index}; use crate::Db; -use super::Type; use super::context::InferContext; use super::diagnostic::{ ESCAPING_LOCAL, ESCAPING_LOOP_VARIABLE, INVALID_ASSIGNMENT, ONCE_CALLED_TWICE, ONCE_NOT_CALLED, TRAILING_LAMBDA_CONTROL_FLOW, }; +use super::{Type, TypeContext, infer_expression_types}; /// basedpython: where a parameter's borrow was declared, and how the diagnostic /// should describe that declaration. @@ -1087,11 +1087,18 @@ fn walk_for_blocks<'db, 'ast, F>( walk_for_blocks(context, &while_.body, &assigned, true, callee_type); walk_for_blocks(context, &while_.orelse, loop_assigned, in_loop, callee_type); } - // a trailing-lambda block is the case we check; its own body is a - // separate scope, so we do not descend into it here + // a trailing-lambda block is the case we check. its body is a scope + // of its own — but a `once` block runs inline, exactly once, so a + // block nested inside it sits inside the loop as much as the `once` + // block does, and its own callee decides whether it is confined. + // (a non-borrow block already reports every capture in its body, + // nested blocks included, so only a `once` one is entered) Stmt::FunctionDef(func) if func.is_trailing_lambda => { if in_loop { check_block_capture(context, func, loop_assigned, callee_type); + if block_callee_is_once(context, func, callee_type) { + walk_for_blocks(context, &func.body, loop_assigned, in_loop, callee_type); + } } } // an ordinary nested function / class is its own scope @@ -1130,6 +1137,44 @@ fn walk_for_blocks<'db, 'ast, F>( } } +/// The type of `block`'s callee. A block directly in the body being checked has +/// it in that body's inference (`callee_type`); one nested inside a `once` block +/// has its callee in the `once` block's own scope, which that inference never +/// enters — so it is read from the callee's standalone inference instead, which +/// the semantic index registers for every trailing-lambda callee (and which the +/// block's own `it` typing already reads, so it is not a cycle). +fn block_callee_type<'db, 'ast, F>( + context: &InferContext<'db, 'ast>, + block: &'ast ast::StmtFunctionDef, + callee_type: &F, +) -> Option> +where + F: Fn(&'ast Expr) -> Option>, +{ + let callee = block.trailing_lambda_callee()?; + if let Some(ty) = callee_type(callee) { + return Some(ty); + } + let db = context.db(); + let expression = semantic_index(db, context.program_file()).try_expression(callee)?; + infer_expression_types(db, expression, TypeContext::default()).try_expression_type(callee) +} + +/// Whether `block`'s callee marks its callback `once` — the block then runs +/// inline, exactly once. Anything unresolvable is not `once`. +fn block_callee_is_once<'db, 'ast, F>( + context: &InferContext<'db, 'ast>, + block: &'ast ast::StmtFunctionDef, + callee_type: &F, +) -> bool +where + F: Fn(&'ast Expr) -> Option>, +{ + block_callee_type(context, block, callee_type).is_some_and(|callee| { + crate::types::trailing_lambda::callee_callback_is_once(context.db(), callee) + }) +} + /// Reports each loop variable a trailing-lambda block captures, unless its callee /// confines the block (a `local` / `once` callee) or cannot be resolved. fn check_block_capture<'db, 'ast, F>( @@ -1142,9 +1187,7 @@ fn check_block_capture<'db, 'ast, F>( { // a `local` / `once` callee runs the block synchronously (safe); an opaque // callee is left alone. only a resolved non-borrow callee is a concern - let resolved_non_borrow = block - .trailing_lambda_callee() - .and_then(callee_type) + let resolved_non_borrow = block_callee_type(context, block, callee_type) .and_then(|callee| { crate::types::trailing_lambda::callee_callback_is_borrowed(context.db(), callee) }) diff --git a/crates/ty_python_semantic/src/types/state_invalidations.rs b/crates/ty_python_semantic/src/types/state_invalidations.rs new file mode 100644 index 0000000000..47efe93c27 --- /dev/null +++ b/crates/ty_python_semantic/src/types/state_invalidations.rs @@ -0,0 +1,1387 @@ +//! basedpython-ui: which composition scopes a state write invalidates. +//! +//! [`state_reads`](super::state_reads) recovers what a composition reads; +//! this module runs that the other way, from a *write* site: the composables, +//! root blocks and `derived` computations whose composition depends on the +//! place being written, so an editor can show `count.value += step` followed +//! by `invalidates Counter` at the end of the statement. +//! +//! The runtime's answer is exact and dynamic — a write notifies the trackers +//! that read the cell during their last run, and its trace records which. The +//! static answer mirrors the runtime's subscription rules over the static read +//! sets: +//! +//! - a composable's *own* scope is subscribed to what its body, the content +//! blocks written in it and the plain functions it calls while composing +//! read ([`scope_state_reads`]). A composable callee reads for a scope of +//! its own, so a place handed to one is followed one hop at a time, through +//! the arguments the call writes, rather than lifted into the caller: a +//! child that reads its parameter is named instead of the parent that only +//! forwards it +//! - a composable called *with a content block* (`Card(count):`) is the +//! exception: the runtime runs it inline, re-running it with its parent, and +//! what it reads — its own cells included — subscribes the parent's scope, +//! through as many inline parents as there are. Its reads are lifted into +//! the caller's scope like a plain callee's, and the child is still named +//! - a `derived` is a tracker of its own: a write to what its lambda reads +//! invalidates the derived, and then whatever reads the derived +//! - a `remember` computation reads on behalf of the scope that made it +//! - a nested composable that captures a slot of an enclosing one reads it +//! as a dependency of its own +//! - the `root` of `run_app`, `compose_test` and `Runtime.set_root` is a +//! scope like a composable's +//! +//! A written name stands for a *slot* when it was bound, while composing, to +//! what a call returned — `let count = state(0)`, in the body or in a content +//! block written in it. A name bound to another place (`let alias = count`, +//! `let cell = model.count`) is followed to that place, binding by binding, in +//! the body and in a handler alike. A slot's readers are looked for +//! throughout its file: the scopes that can see the binding, and the parents +//! an inline child's reads were lifted into. A parameter is followed, through +//! the callers that fill it, to wherever the argument came from; a +//! module-level slot may be read anywhere in its file. What a walk in one +//! file cannot see, the set says so with `…`: a caller, an inline parent or a +//! reader of a module-level slot in another file; a callee reached through a +//! `dynamic` value; an unpacked argument; and a written name that is not a +//! slot at all — a loop or comprehension target, a value bound after +//! composing or outside every composition, a subscript — whose readers could +//! be anywhere. `nothing` is said only of a slot no composition reads. +//! +//! Everything here is asked by the editor alone, after inference: it reads +//! other scopes' inferred types freely, which the checks that run *during* +//! inference cannot (see `type_in_scope` in `composition.rs`), and nothing in +//! `check_scope` reaches it. + +use ruff_db::files::File; +use ruff_db::parsed::{ParsedModuleRef, parsed_module}; +use ruff_python_ast::name::Name; +use ruff_python_ast::visitor::{Visitor, walk_expr, walk_stmt}; +use ruff_python_ast::{self as ast, Expr, Stmt}; +use ruff_text_size::{Ranged, TextRange}; +use rustc_hash::{FxHashMap, FxHashSet}; +use ty_module_resolver::{KnownModule, resolve_module_confident}; +use ty_python_core::definition::{Definition, DefinitionKind, DefinitionNodeKey}; +use ty_python_core::scope::{FileScopeId, NodeWithScopeKind, ScopeId}; +use ty_python_core::{ProgramFile, SemanticIndex, semantic_index}; + +use crate::types::composition::{ + BlockKind, CompositionOwner, RootEntry, block_kind, composition_of_scope, computation_kind, +}; +use crate::types::dedicated::basedpython_ui::{ObservableKind, is_composable, observable_kind}; +use crate::types::function::{FunctionDecorators, FunctionLiteral, KnownFunction, OverloadLiteral}; +use crate::types::state_reads::{ + ParameterInfo, PlaceRoot, PlaceRootKind, ReadsCollector, StatePlace, StateReadEffects, + StateReads, body_state_read_effects, function_parameters, lambda_state_reads, + parameter_definitions, +}; +use crate::types::trailing_lambda::callee_accepts_block; +use crate::types::{ProgramEnvironment, Type, infer_definition_types}; +use crate::{Db, FxIndexSet}; + +// --------------------------------------------------------------------------- +// what a write invalidates +// --------------------------------------------------------------------------- + +/// One scope a write invalidates. +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) enum Invalidated<'db> { + /// a composable, whose scope re-runs + Composable(FunctionLiteral<'db>), + /// the root of an entry point — the runtime's `root` scope: the block of + /// `run_app` / `compose_test`, or the function or lambda handed to + /// `Runtime.set_root` + Root(ScopeId<'db>), + /// a `derived` computation, which recomputes and then invalidates its own + /// readers when its value changed + Derived(Definition<'db>), +} + +/// One scope a write invalidates, with how the editor names and reaches it. +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) struct InvalidatedScope<'db> { + pub(crate) what: Invalidated<'db>, + /// the composable's name, `root`, or the name the derived is bound to — + /// what the runtime's trace calls the scope + pub(crate) name: Name, + /// the range of the name — for a root, of the entry point called or of + /// the argument handed to `set_root` — in the file `what` is in + pub(crate) declaration: TextRange, +} + +impl<'db> InvalidatedScope<'db> { + /// the file `declaration` is in + pub(crate) fn file(&self, db: &'db dyn Db) -> File { + match self.what { + Invalidated::Composable(function) => function.last_definition.file(db), + Invalidated::Root(scope) => scope.file(db), + Invalidated::Derived(definition) => definition.file(db), + } + } +} + +/// The scopes a write to one place invalidates. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Default, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) struct StateInvalidations<'db> { + /// in declaration order, the written slot's own file first + scopes: Box<[InvalidatedScope<'db>]>, + /// whether a reader may have been missed: a caller, an inline parent or a + /// reader of a module-level slot in another file; a callee that cannot + /// be followed; an argument that cannot be seen into; a written name that + /// is not a slot + opaque: bool, +} + +/// A place a body writes, interned so that its readers are computed once +/// however many sites write it — the three buttons of a counter share one +/// answer. +#[salsa::interned(debug, heap_size = ruff_memory_usage::heap_size)] +pub(crate) struct WrittenPlace<'db> { + /// the place as the writing body sees it: its root's kind says whether + /// the slot is that body's own, its function's parameter or a + /// module-level name, which decides where readers are looked for + #[returns(ref)] + place: StatePlace<'db>, +} + +// The Salsa heap is tracked separately. +impl get_size2::GetSize for WrittenPlace<'_> {} + +/// The scopes a write in `program_file` to `written` invalidates. +#[salsa::tracked(returns(ref), heap_size = ruff_memory_usage::heap_size)] +pub(crate) fn place_invalidations<'db>( + db: &'db dyn Db, + program_file: ProgramFile<'db>, + written: WrittenPlace<'db>, +) -> StateInvalidations<'db> { + let file = program_file.file(db); + let mut resolver = Resolver { + db, + found: FxIndexSet::default(), + opaque: false, + visited: FxHashSet::default(), + handed: FxHashSet::default(), + }; + resolver.follow(written.place(db), true); + resolver.close_over_inline_children(); + let mut scopes = resolver.found.into_iter().collect::>(); + scopes.sort_by_key(|scope| (scope.file(db) != file, scope.declaration.start())); + StateInvalidations { + scopes: scopes.into_boxed_slice(), + opaque: resolver.opaque, + } +} + +/// Finds the readers of a place, following it through the call graph. +struct Resolver<'db> { + db: &'db dyn Db, + found: FxIndexSet>, + opaque: bool, + /// the places already followed, with whether their callers were + visited: FxHashSet<(StatePlace<'db>, bool)>, + /// the plain bodies a place was already handed into, so that two helpers + /// calling each other end + handed: FxHashSet<(ScopeId<'db>, StatePlace<'db>)>, +} + +impl<'db> Resolver<'db> { + /// Find the readers of `place`. + /// + /// `through_callers` follows a parameter up to the arguments its callers + /// write. That is right for the place a write site names — the slot came + /// from a caller — and wrong for a parameter reached by handing a place + /// *down* to a callee: the callee's other callers fill it with other + /// slots, which the write does not touch. + fn follow(&mut self, place: &StatePlace<'db>, through_callers: bool) { + if !self.visited.insert((place.clone(), through_callers)) { + return; + } + let db = self.db; + let definition = place.root.definition; + let program_file = definition.program_file(db); + let index = semantic_index(db, program_file); + let compositions = file_compositions(db, program_file); + match place.root.kind { + PlaceRootKind::Global => { + self.sweep(compositions, index, place, None); + // any file may import a module-level slot + self.opaque = true; + } + PlaceRootKind::Local => { + let module = parsed_module(db, program_file.python_file(db)).load(db); + match local_binding(db, program_file, index, &module, definition) { + LocalBinding::Slot { + owner_scope, + inline_elsewhere, + } => { + self.sweep(compositions, index, place, Some(owner_scope)); + // an inline parent in another file cannot be seen + self.opaque |= inline_elsewhere; + } + LocalBinding::Parameter { scope, position } => { + self.sweep(compositions, index, place, Some(scope)); + if through_callers { + self.callers(compositions, program_file, scope, position, place); + // a caller in another file cannot be seen + self.opaque = true; + } + } + LocalBinding::Unknown => { + // whoever reads the same binding re-runs for sure; + // what else holds the value cannot be seen + self.sweep(compositions, index, place, Some(definition.file_scope(db))); + self.opaque = true; + } + } + } + PlaceRootKind::Parameter { index: position } => { + let scope = definition.file_scope(db); + self.sweep(compositions, index, place, Some(scope)); + if through_callers { + self.callers(compositions, program_file, scope, position, place); + // a caller in another file cannot be seen + self.opaque = true; + } + } + } + } + + /// Look for readers of `place` in every composition scope and computation + /// of a file. + /// + /// A place is identified by its binding, so any scope whose reads name it + /// is a reader — the scopes that can see the binding, and the parents an + /// inline child's reads were lifted into. Only where the binding is + /// visible — the scopes within `region`, `None` for a module-level slot + /// visible from everywhere — can a call hand it on, and can a callee that + /// could not be followed, or an argument that cannot be seen into, have + /// received it: those scopes alone are followed further and make the + /// answer opaque. + fn sweep( + &mut self, + compositions: &FileCompositions<'db>, + index: &SemanticIndex<'db>, + place: &StatePlace<'db>, + region: Option, + ) { + let db = self.db; + let in_region = |scope: FileScopeId| { + region.is_none_or(|region| index.ancestor_scopes(scope).any(|(id, _)| id == region)) + }; + + for scope in &compositions.scopes { + let reads = match scope.what { + Invalidated::Composable(function) => function_scope_reads(db, function), + Invalidated::Root(root) => root_state_reads(db, root).clone(), + Invalidated::Derived(_) => continue, + }; + if reads.places.iter().any(|read| read.same_place(place)) { + self.found.insert(scope.invalidated()); + } + if !in_region(scope.scope.file_scope_id(db)) { + continue; + } + self.opaque |= reads.opaque; + match scope.what { + Invalidated::Composable(function) => { + for overload in function.iter_overloads_and_implementation(db) { + self.hand_on(body_state_read_effects(db, overload), place); + } + } + Invalidated::Root(root) => { + self.hand_on(root_state_read_effects(db, root), place); + } + Invalidated::Derived(_) => {} + } + } + + for computation in &compositions.computations { + let reads = lambda_scope_state_reads(db, computation.lambda); + if in_region(computation.lambda.file_scope_id(db)) { + self.opaque |= reads.opaque; + } + if !reads.places.iter().any(|read| read.same_place(place)) { + continue; + } + match &computation.kind { + // a `remember` reads on behalf of the scope that made it + ComputationKind::Remember => match &computation.owner { + Some(owner) => { + self.found.insert(owner.clone()); + } + None => self.opaque = true, + }, + // a derived is invalidated, and then so is whatever reads it + ComputationKind::Derived(Some(binding)) => { + self.found.insert(InvalidatedScope { + what: Invalidated::Derived(binding.definition), + name: binding.name.clone(), + declaration: binding.declaration, + }); + self.follow(&binding.place(), false); + } + // a derived nothing is bound to is read through whatever + // holds it, which cannot be named + ComputationKind::Derived(None) => self.opaque = true, + } + } + } + + /// Follow `place` into every callee of `effects` it is handed to, as the + /// parameter the argument fills: a composable callee is followed as a + /// scope of its own; a plain one — a helper that hands the slot on — is + /// walked for the calls *it* makes, with the slot under its parameter's + /// name, and under its own name when the helper captures it. + fn hand_on(&mut self, effects: &StateReadEffects<'db>, place: &StatePlace<'db>) { + let db = self.db; + for call in &effects.calls { + let composable = is_composable_literal(db, call.callee); + for overload in call.callee.iter_overloads_and_implementation(db) { + let callee_effects = (!composable).then(|| body_state_read_effects(db, overload)); + // a plain callee that calls nothing can hand nothing on + if callee_effects.is_some_and(|effects| effects.calls.is_empty()) { + continue; + } + if let Some(callee_effects) = callee_effects { + self.hand_into(overload, callee_effects, place); + } + for parameter in parameter_roots(db, overload) { + let Some(argument) = call.argument(parameter.index, ¶meter.root.name) + else { + // the parameter is filled by an unpacked argument, + // which may well be the slot + if call.may_fill_unseen(parameter.index, ¶meter.root.name) { + self.opaque = true; + } + continue; + }; + if argument.root.definition != place.root.definition + || !place.path.starts_with(&argument.path) + { + continue; + } + let handed = StatePlace::at_root(parameter.root.clone()) + .extended(&place.path[argument.path.len()..]); + match callee_effects { + Some(callee_effects) => self.hand_into(overload, callee_effects, &handed), + None => self.follow(&handed, false), + } + } + } + } + } + + /// [`Self::hand_on`] for the body of the plain function `overload`, once + /// per place. + fn hand_into( + &mut self, + overload: OverloadLiteral<'db>, + effects: &StateReadEffects<'db>, + place: &StatePlace<'db>, + ) { + if self + .handed + .insert((overload.body_scope(self.db), place.clone())) + { + self.hand_on(effects, place); + } + } + + /// Add the composables every found scope calls with a content block, + /// and theirs, and so on down. + /// + /// The runtime never skips a child called with a block: it re-runs + /// whenever its parent does, whether or not it reads what was written. + /// The calls of a plain function the scope calls while composing are the + /// scope's own. + fn close_over_inline_children(&mut self) { + let db = self.db; + let mut pending: Vec> = self.found.iter().cloned().collect(); + let mut walked: FxHashSet> = FxHashSet::default(); + while let Some(scope) = pending.pop() { + let mut bodies = Vec::new(); + match scope.what { + Invalidated::Composable(function) => { + for overload in function.iter_overloads_and_implementation(db) { + if walked.insert(overload.body_scope(db)) { + bodies.push(body_state_read_effects(db, overload)); + } + } + } + Invalidated::Root(root) => { + if walked.insert(root) { + bodies.push(root_state_read_effects(db, root)); + } + } + Invalidated::Derived(_) => continue, + } + while let Some(effects) = bodies.pop() { + for call in &effects.calls { + if !is_composable_literal(db, call.callee) { + for overload in call.callee.iter_overloads_and_implementation(db) { + if walked.insert(overload.body_scope(db)) { + effects_of_plain_callee(db, overload, &mut bodies); + } + } + continue; + } + if !call.inline { + continue; + } + let compositions = + file_compositions(db, call.callee.last_definition.program_file(db)); + let child = compositions + .scopes + .iter() + .find(|scope| scope.what == Invalidated::Composable(call.callee)) + .map(CompositionScope::invalidated); + if let Some(child) = child + && self.found.insert(child.clone()) + { + pending.push(child); + } + } + } + } + } + + /// Follow the parameter at `position` of the function whose body is + /// `function_scope` back to what each caller in the file writes for it. + fn callers( + &mut self, + compositions: &FileCompositions<'db>, + program_file: ProgramFile<'db>, + function_scope: FileScopeId, + position: Option, + place: &StatePlace<'db>, + ) { + let db = self.db; + let body = function_scope.to_scope_id(db, program_file); + let mut arguments = Vec::new(); + for effects in compositions.effects(db) { + for call in &effects.calls { + if !call + .callee + .iter_overloads_and_implementation(db) + .any(|overload| overload.body_scope(db) == body) + { + continue; + } + if let Some(argument) = call.argument(position, &place.root.name) { + arguments.push(argument.extended(&place.path)); + } + } + } + for argument in arguments { + self.follow(&argument, true); + } + } +} + +/// Push the effects of the plain function `overload` onto `bodies`, when it +/// calls anything while composing. +fn effects_of_plain_callee<'db>( + db: &'db dyn Db, + overload: OverloadLiteral<'db>, + bodies: &mut Vec<&'db StateReadEffects<'db>>, +) { + let effects = body_state_read_effects(db, overload); + if !effects.calls.is_empty() { + bodies.push(effects); + } +} + +/// whether `function` is decorated with the framework's `@composable` +fn is_composable_literal<'db>(db: &'db dyn Db, function: FunctionLiteral<'db>) -> bool { + function + .iter_overloads_and_implementation(db) + .any(|overload| overload.has_known_decorator(db, FunctionDecorators::COMPOSABLE)) +} + +/// What a name bound in a function stands for, when a body writes it. +enum LocalBinding { + /// a slot: a name bound while composing to what a call returned — `let + /// count = state(0)`, in a composable's body, in a content block written + /// in it, or in a root. Its readers are wherever the name is visible, and + /// the parents its scope runs inline in + Slot { + /// the scope of the composition the slot belongs to — for a slot + /// declared in a content block, the composable's body, not the block + owner_scope: FileScopeId, + /// whether the composition can be called with a content block from + /// a file the walk cannot see, so that an unseen parent may be + /// subscribed to the slot: a composable with a callable last + /// parameter, unless it is `private` to its file + inline_elsewhere: bool, + }, + /// a parameter of an enclosing function, seen from a scope nested in it: + /// the callers of that function fill it + Parameter { + scope: FileScopeId, + position: Option, + }, + /// anything else — a loop or comprehension target, a block's `it`, a + /// lambda's parameter, a value bound after composing or outside every + /// composition, a subscript, an unpacking: what the name holds may have + /// readers the walk cannot see + Unknown, +} + +/// What the name `definition` binds stands for. +fn local_binding<'db>( + db: &'db dyn Db, + program_file: ProgramFile<'db>, + index: &SemanticIndex<'db>, + module: &ParsedModuleRef, + definition: Definition<'db>, +) -> LocalBinding { + let scope = definition.file_scope(db); + let binds_call = match definition.kind(db) { + DefinitionKind::Parameter(_) => { + // a parameter of a `def` is filled by the def's callers; a block's + // `it` or a lambda's parameter by whatever calls the callback + if let NodeWithScopeKind::Function(function) = index.scope(scope).node() { + let function = function.node(module); + if !function.is_trailing_lambda + && let Some(parameter) = function_parameters(index, function).get(&definition) + { + return LocalBinding::Parameter { + scope, + position: parameter.index, + }; + } + } + return LocalBinding::Unknown; + } + DefinitionKind::Assignment(assignment) => { + assignment.unpack().is_none() && assignment.value(module).is_call_expr() + } + DefinitionKind::AnnotatedAssignment(assignment) => { + assignment.value(module).is_some_and(Expr::is_call_expr) + } + _ => false, + }; + if !binds_call { + return LocalBinding::Unknown; + } + let file = program_file.file(db); + let Some(composition) = composition_of_scope(db, file, index, module, scope) else { + return LocalBinding::Unknown; + }; + if !composition.runs_while_composing() { + return LocalBinding::Unknown; + } + let inline_elsewhere = match composition.owner { + CompositionOwner::Composable(function) => { + !function.has_known_decorator(db, FunctionDecorators::PRIVATE) + && callee_accepts_block(db, Type::FunctionLiteral(function)) + } + CompositionOwner::Root(_) => false, + }; + LocalBinding::Slot { + owner_scope: composition.owner_scope, + inline_elsewhere, + } +} + +// --------------------------------------------------------------------------- +// what subscribes a scope +// --------------------------------------------------------------------------- + +/// The observables `overload` reads for its *own* scope while composing: what +/// its body and content blocks read, and what the plain functions and the +/// composables called with a content block read on its behalf — mapped +/// through the arguments, kept when they are the callee's own cells or a name +/// it captures from an enclosing function. A composable callee without a +/// block is left out: it reads for a scope of its own. +#[salsa::tracked( + returns(ref), + cycle_initial = |_, _, _| StateReads::default(), + heap_size = ruff_memory_usage::heap_size, +)] +pub(crate) fn scope_state_reads<'db>( + db: &'db dyn Db, + overload: OverloadLiteral<'db>, +) -> StateReads<'db> { + let parameters = parameter_roots(db, overload) + .iter() + .map(|parameter| (parameter.root.definition, parameter.index)) + .collect(); + resolve_scope_reads( + db, + overload.file(db), + body_state_read_effects(db, overload), + Some(overload.body_scope(db)), + ¶meters, + ) +} + +/// [`scope_state_reads`] over every overload and the implementation of +/// `function`. +fn function_scope_reads<'db>(db: &'db dyn Db, function: FunctionLiteral<'db>) -> StateReads<'db> { + let mut places = FxIndexSet::default(); + let mut opaque = false; + for overload in function.iter_overloads_and_implementation(db) { + let reads = scope_state_reads(db, overload); + places.extend(reads.places.iter().cloned()); + opaque |= reads.opaque; + } + StateReads::new(db, function.last_definition.file(db), places, opaque) +} + +/// [`StateReadEffects`] of the root whose scope is `scope`: what the `root` +/// of `run_app` / `compose_test`, or the function or lambda handed to +/// `Runtime.set_root`, reads and calls — walked as a composable body with no +/// parameters of its own. +#[salsa::tracked(returns(ref), heap_size = ruff_memory_usage::heap_size)] +fn root_state_read_effects<'db>(db: &'db dyn Db, scope: ScopeId<'db>) -> StateReadEffects<'db> { + let program_file = scope.program_file(db); + if !program_file.file(db).source_type(db).is_basedpython() { + return StateReadEffects::default(); + } + let env = ProgramEnvironment::from_file(program_file); + if !framework_resolves(db, &env) { + return StateReadEffects::default(); + } + let module = parsed_module(db, program_file.python_file(db)).load(db); + let index = semantic_index(db, program_file); + let mut collector = ReadsCollector::new( + db, + env, + program_file, + index, + &module, + FxHashMap::default(), + scope.file_scope_id(db), + ); + match scope.node(db) { + NodeWithScopeKind::Function(root) => collector.visit_body(&root.node(&module).body), + NodeWithScopeKind::Lambda(root) => collector.visit_expr(&root.node(&module).body), + _ => return StateReadEffects::default(), + } + collector.finish() +} + +/// The observables the root whose scope is `scope` reads for its own scope +/// — the runtime's `root`. +#[salsa::tracked(returns(ref), heap_size = ruff_memory_usage::heap_size)] +fn root_state_reads<'db>(db: &'db dyn Db, scope: ScopeId<'db>) -> StateReads<'db> { + resolve_scope_reads( + db, + scope.file(db), + root_state_read_effects(db, scope), + None, + &FxHashMap::default(), + ) +} + +/// The observables the `derived` / `remember` computation written as the +/// lambda whose scope is `scope` reads. +#[salsa::tracked(returns(ref), heap_size = ruff_memory_usage::heap_size)] +fn lambda_scope_state_reads<'db>(db: &'db dyn Db, scope: ScopeId<'db>) -> StateReads<'db> { + let NodeWithScopeKind::Lambda(lambda) = scope.node(db) else { + return StateReads::default(); + }; + let program_file = scope.program_file(db); + let module = parsed_module(db, program_file.python_file(db)).load(db); + let index = semantic_index(db, program_file); + lambda_state_reads(db, program_file, index, &module, lambda.node(&module)) +} + +/// Union the reads of `effects` that subscribe the scope they belong to, +/// following each plain callee and each composable called with a block, and +/// stopping at each composable called without one. +/// +/// `self_body_scope` drops a directly recursive call, as `resolve_state_reads` +/// does; `parameters` are the body's own parameters, which decide how a name a +/// callee captures from this body is seen from here. +fn resolve_scope_reads<'db>( + db: &'db dyn Db, + file: File, + effects: &StateReadEffects<'db>, + self_body_scope: Option>, + parameters: &FxHashMap, Option>, +) -> StateReads<'db> { + let mut places: FxIndexSet> = effects.reads.iter().cloned().collect(); + let mut opaque = effects.opaque; + + for call in &effects.calls { + if call + .callee + .iter_overloads_and_implementation(db) + .any(|overload| Some(overload.body_scope(db)) == self_body_scope) + { + continue; + } + // a composable callee reads for a scope of its own — unless the call + // carries a content block, which the runtime runs inline: the child + // re-runs with this scope, and what it reads subscribes this scope + if !call.inline && is_composable_literal(db, call.callee) { + continue; + } + let callee = function_scope_reads(db, call.callee); + opaque |= callee.opaque; + for place in &callee.places { + match place.root.kind { + PlaceRootKind::Global => { + places.insert(place.clone()); + } + PlaceRootKind::Parameter { index } => { + if let Some(argument) = call.argument(index, &place.root.name) { + places.insert(argument.extended(&place.path)); + } else if call.may_fill_unseen(index, &place.root.name) { + // the callee reads a parameter an unpacked argument + // fills, from something the walk cannot see into + opaque = true; + } + } + // a cell the callee makes while composing is a slot of this + // scope, read on its behalf — as is a name the callee + // captures from a function enclosing it, which is seen from + // here as this body sees the binding + PlaceRootKind::Local => { + places.insert(reclassified(db, place, parameters)); + } + } + } + } + + StateReads::new(db, file, places, opaque) +} + +/// `place` with its root's kind as the body whose parameters are +/// `parameters` sees the binding +fn reclassified<'db>( + db: &'db dyn Db, + place: &StatePlace<'db>, + parameters: &FxHashMap, Option>, +) -> StatePlace<'db> { + let definition = place.root.definition; + let kind = match parameters.get(&definition) { + Some(index) => PlaceRootKind::Parameter { index: *index }, + None if definition.file_scope(db).is_global() => PlaceRootKind::Global, + None => PlaceRootKind::Local, + }; + StatePlace { + root: PlaceRoot { + kind, + ..place.root.clone() + }, + path: place.path.clone(), + } +} + +/// One parameter of a function as a place root, for the callee's side of a +/// call: a place handed to the call is seen inside the callee from here. +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) struct ParameterRoot<'db> { + /// its position among the positional parameters, `None` for a + /// keyword-only one + index: Option, + root: PlaceRoot<'db>, +} + +/// The parameters of `overload` a call can fill, as place roots. +#[salsa::tracked(returns(ref), heap_size = ruff_memory_usage::heap_size)] +fn parameter_roots<'db>( + db: &'db dyn Db, + overload: OverloadLiteral<'db>, +) -> Box<[ParameterRoot<'db>]> { + let file = overload.file(db); + let module = parsed_module(db, overload.python_file(db)).load(db); + let index = semantic_index(db, overload.program_file(db)); + let node = overload.node(db, file, &module); + parameter_definitions(index, node) + .map(|(index, parameter, definition)| ParameterRoot { + index, + root: PlaceRoot { + definition, + kind: PlaceRootKind::Parameter { index }, + name: parameter.name.id.clone(), + declaration: parameter.name.range(), + }, + }) + .collect() +} + +// --------------------------------------------------------------------------- +// the composition scopes and computations of a file +// --------------------------------------------------------------------------- + +/// A composition scope of a file: a composable's body, or the root of an +/// entry point. +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +struct CompositionScope<'db> { + /// the scope of the body — the composable's, or the root's + scope: ScopeId<'db>, + /// the scope as the editor names it: `Invalidated::Composable` or + /// `Invalidated::Root`, never `Invalidated::Derived` + what: Invalidated<'db>, + name: Name, + declaration: TextRange, +} + +impl<'db> CompositionScope<'db> { + fn invalidated(&self) -> InvalidatedScope<'db> { + InvalidatedScope { + what: self.what.clone(), + name: self.name.clone(), + declaration: self.declaration, + } + } +} + +/// The name a `derived(...)` result is bound to: `total` in `let total = +/// derived(lambda: ...)`. +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +struct DerivedBinding<'db> { + definition: Definition<'db>, + name: Name, + declaration: TextRange, + /// whether the binding is module-level, so that any function may read it + global: bool, +} + +impl<'db> DerivedBinding<'db> { + /// the binding as a place, seen from the scope that binds it + fn place(&self) -> StatePlace<'db> { + StatePlace::at_root(PlaceRoot { + definition: self.definition, + kind: if self.global { + PlaceRootKind::Global + } else { + PlaceRootKind::Local + }, + name: self.name.clone(), + declaration: self.declaration, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +enum ComputationKind<'db> { + /// `derived(lambda: ...)`, with the name its result is bound to when + /// there is one + Derived(Option>), + /// `remember(lambda: ...)` + Remember, +} + +/// A `derived(lambda: ...)` or `remember(lambda: ...)` written in a file. +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +struct Computation<'db> { + /// the lambda's scope, whose reads are what the computation depends on + lambda: ScopeId<'db>, + /// the composition scope the computation is made in, which a `remember` + /// reads on behalf of + owner: Option>, + kind: ComputationKind<'db>, +} + +/// Everything in a file a write can invalidate, and every body whose calls +/// can hand a slot on. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Default, get_size2::GetSize, salsa::SalsaValue)] +struct FileCompositions<'db> { + scopes: Box<[CompositionScope<'db>]>, + /// the functions of the file that are not composition scopes, whose + /// calls made while composing can fill a parameter with a slot + functions: Box<[FunctionLiteral<'db>]>, + computations: Box<[Computation<'db>]>, +} + +impl<'db> FileCompositions<'db> { + /// the effects of every body of the file that calls while composing + fn effects(&self, db: &'db dyn Db) -> Vec<&'db StateReadEffects<'db>> { + let mut effects = Vec::new(); + for scope in &self.scopes { + match scope.what { + Invalidated::Composable(function) => { + for overload in function.iter_overloads_and_implementation(db) { + effects.push(body_state_read_effects(db, overload)); + } + } + Invalidated::Root(root) => effects.push(root_state_read_effects(db, root)), + Invalidated::Derived(_) => {} + } + } + for function in &self.functions { + for overload in function.iter_overloads_and_implementation(db) { + effects.push(body_state_read_effects(db, overload)); + } + } + effects + } +} + +/// The composition scopes, plain functions and computations of a file. +#[salsa::tracked(returns(ref), heap_size = ruff_memory_usage::heap_size)] +fn file_compositions<'db>( + db: &'db dyn Db, + program_file: ProgramFile<'db>, +) -> FileCompositions<'db> { + let file = program_file.file(db); + if !file.source_type(db).is_basedpython() { + return FileCompositions::default(); + } + let env = ProgramEnvironment::from_file(program_file); + if !framework_resolves(db, &env) { + return FileCompositions::default(); + } + let module = parsed_module(db, program_file.python_file(db)).load(db); + let index = semantic_index(db, program_file); + + // the range of what was handed to `set_root`, when `scope` is its root + let set_root_declaration = |scope: FileScopeId| { + let composition = composition_of_scope(db, file, index, &module, scope)?; + if !matches!( + composition.owner, + CompositionOwner::Root(RootEntry::SetRoot) + ) || composition.owner_scope != scope + { + return None; + } + composition.owner_range() + }; + let root = |scope: ScopeId<'db>, declaration: TextRange| CompositionScope { + scope, + what: Invalidated::Root(scope), + name: Name::new_static("root"), + declaration, + }; + + let mut scopes = Vec::new(); + let mut functions = Vec::new(); + let mut lambdas = Vec::new(); + for scope in index.scope_ids() { + let file_scope = scope.file_scope_id(db); + match index.scope(file_scope).node() { + NodeWithScopeKind::Function(function) => { + let node = function.node(&module); + if node.is_trailing_lambda { + if let BlockKind::Root(_) = block_kind(db, index, node) + && let Some(callee) = node.trailing_lambda_callee() + { + scopes.push(root(scope, callee.range())); + } + continue; + } + let Some(definition) = index.try_definition(node) else { + continue; + }; + let Some(function) = + infer_definition_types(db, definition).function_type(definition) + else { + continue; + }; + let literal = function.literal(db); + if is_composable(db, function) { + scopes.push(CompositionScope { + scope, + what: Invalidated::Composable(literal), + name: node.name.id.clone(), + declaration: node.name.range(), + }); + } else if let Some(declaration) = set_root_declaration(file_scope) { + scopes.push(root(scope, declaration)); + } else { + functions.push(literal); + } + } + NodeWithScopeKind::Lambda(lambda) => { + if let Some(declaration) = set_root_declaration(file_scope) { + scopes.push(root(scope, declaration)); + } else { + lambdas.push((scope, lambda.node(&module))); + } + } + _ => {} + } + } + + let mut computations = Vec::new(); + for (scope, lambda) in lambdas { + let file_scope = scope.file_scope_id(db); + let Some(known) = computation_kind(db, index, &module, file_scope, lambda.range()) else { + continue; + }; + let owner = + composition_of_scope(db, file, index, &module, file_scope).and_then(|composition| { + scopes + .iter() + .find(|scope| scope.scope.file_scope_id(db) == composition.owner_scope) + .map(CompositionScope::invalidated) + }); + let kind = if known == KnownFunction::BasedpythonUiRemember { + ComputationKind::Remember + } else { + ComputationKind::Derived(derived_binding(index, &module, file_scope, lambda)) + }; + computations.push(Computation { + lambda: scope, + owner, + kind, + }); + } + + FileCompositions { + scopes: scopes.into_boxed_slice(), + functions: functions.into_boxed_slice(), + computations: computations.into_boxed_slice(), + } +} + +/// The name the `derived(...)` call whose `compute` is `lambda` (the scope +/// `scope`) is bound to, when the call is the whole value of an assignment to +/// one name in the enclosing scope. +fn derived_binding<'db>( + index: &SemanticIndex<'db>, + module: &ParsedModuleRef, + scope: FileScopeId, + lambda: &ast::ExprLambda, +) -> Option> { + let parent = index.parent_scope_id(scope)?; + let body: &[Stmt] = match index.scope(parent).node() { + NodeWithScopeKind::Function(function) => &function.node(module).body, + NodeWithScopeKind::Module => &module.syntax().body, + NodeWithScopeKind::Class(class) => &class.node(module).body, + _ => return None, + }; + let mut finder = BindingFinder { + compute: lambda.range(), + found: None, + }; + finder.visit_body(body); + let (target, definition) = finder.found?; + Some(DerivedBinding { + definition: index.try_definition(definition)?, + name: target.id.clone(), + declaration: target.range(), + global: parent.is_global(), + }) +} + +/// Finds, in a scope's own statements, the assignment whose value is the +/// call with `compute` as its computation: the name bound, and the key its +/// definition is filed under — the target for `total = derived(...)`, the +/// statement for `let total = derived(...)`, which is an annotated +/// assignment. +struct BindingFinder<'a> { + compute: TextRange, + found: Option<(&'a ast::ExprName, DefinitionNodeKey)>, +} + +impl BindingFinder<'_> { + fn binds(&self, value: &Expr) -> bool { + matches!( + value, + Expr::Call(call) + if call + .arguments + .find_argument_value("compute", 0) + .is_some_and(|compute| compute.range() == self.compute) + ) + } +} + +impl<'a> Visitor<'a> for BindingFinder<'a> { + fn visit_stmt(&mut self, stmt: &'a Stmt) { + if self.found.is_some() { + return; + } + match stmt { + // a nested scope's statements are not this scope's + Stmt::FunctionDef(_) | Stmt::ClassDef(_) => {} + Stmt::Assign(assign) => { + if let [Expr::Name(target)] = assign.targets.as_slice() + && self.binds(&assign.value) + { + let key = >::from(target); + self.found = Some((target, key)); + } + } + Stmt::AnnAssign(assign) => { + if let Expr::Name(target) = assign.target.as_ref() + && let Some(value) = assign.value.as_deref() + && self.binds(value) + { + let key = >::from(assign); + self.found = Some((target, key)); + } + } + _ => walk_stmt(self, stmt), + } + } + + fn visit_expr(&mut self, _expr: &'a Expr) {} +} + +/// whether the framework resolves in the program `env` belongs to +fn framework_resolves<'db>(db: &'db dyn Db, env: &ProgramEnvironment<'db>) -> bool { + resolve_module_confident( + db, + env.resolver_environment(db), + &KnownModule::BasedpythonUiRuntime.name(), + ) + .is_some() +} + +// --------------------------------------------------------------------------- +// a write site +// --------------------------------------------------------------------------- + +/// Where a statement or a lambda body writes observables. +#[derive(Clone, Copy)] +pub enum WriteSite<'a> { + /// a statement: its own expressions, not those of the statements nested + /// in it, which are write sites of their own + Statement(&'a Stmt), + /// a lambda's body — `on_click=lambda: count.set(0)` + Lambda(&'a ast::ExprLambda), +} + +/// What the writes of one site invalidate. +pub(crate) struct SiteInvalidations<'db> { + /// in declaration order, the site's own file first + pub(crate) scopes: Vec>, + /// whether a reader may have been missed, or a written observable has no + /// name to look readers up by + pub(crate) opaque: bool, + /// where the last write of the site is spelled — `count.value`, + /// `todos.append`, `table["a"]` — for placing a hint on its line when the + /// site itself spans more than one + pub(crate) anchor: TextRange, +} + +/// The scopes the observable writes of `site` invalidate. `None` when the +/// site writes no observable, and when it runs while composing — such a +/// write is a diagnostic, not something to trace. +pub(crate) fn site_invalidations<'db>( + db: &'db dyn Db, + program_file: ProgramFile<'db>, + site: WriteSite<'_>, +) -> Option> { + let file = program_file.file(db); + if !file.source_type(db).is_basedpython() { + return None; + } + + // the syntax alone says whether anything here could be a write, so the + // common statement costs no type lookup at all + let mut candidates = Candidates::default(); + match site { + WriteSite::Statement(stmt) => candidates.visit_stmt(stmt), + WriteSite::Lambda(lambda) => candidates.visit_expr(&lambda.body), + } + if candidates.found.is_empty() { + return None; + } + + let env = ProgramEnvironment::from_file(program_file); + if !framework_resolves(db, &env) { + return None; + } + let module = parsed_module(db, program_file.python_file(db)).load(db); + let index = semantic_index(db, program_file); + + let writes = candidates + .found + .into_iter() + .filter_map(|expr| resolve_write(db, &env, program_file, index, &module, expr)) + .collect::>(); + let (first, last) = match writes.as_slice() { + [] => return None, + [first, .., last] | [first @ last] => (first, last), + }; + if !runs_after_composing(db, file, index, &module, first.scope) { + return None; + } + + let mut found = FxIndexSet::default(); + let mut opaque = false; + for write in &writes { + match &write.place { + Some(place) => { + let written = WrittenPlace::new(db, place.clone()); + let invalidations = place_invalidations(db, program_file, written); + found.extend(invalidations.scopes.iter().cloned()); + opaque |= invalidations.opaque; + } + None => opaque = true, + } + } + let mut scopes = found.into_iter().collect::>(); + scopes.sort_by_key(|scope| (scope.file(db) != file, scope.declaration.start())); + Some(SiteInvalidations { + scopes, + opaque, + anchor: last.range, + }) +} + +/// One observable write of a site. +struct StateWrite<'db> { + /// the expression that spells the write + range: TextRange, + /// the place written; `None` for an observable with no name to look + /// readers up by (`state(0).value = 1`, `cells[0].set(1)`), and for a + /// name bound to such a thing + place: Option>, + /// the scope the write is in + scope: FileScopeId, +} + +/// `expr`, a syntactic candidate, as the observable write it is — or `None` +/// when its receiver is not an observable, or the method is not one of that +/// observable's mutators. +fn resolve_write<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + program_file: ProgramFile<'db>, + index: &SemanticIndex<'db>, + module: &ParsedModuleRef, + expr: &Expr, +) -> Option> { + let scope = index.try_expression_scope_id(expr)?; + let collector = ReadsCollector::new( + db, + env.clone(), + program_file, + index, + module, + enclosing_parameters(index, module, scope), + scope, + ); + match expr { + // `count.value = 1` + Expr::Attribute(attribute) => (collector.observable_of(&attribute.value) + == Some(ObservableKind::State)) + .then(|| StateWrite { + range: attribute.range(), + place: collector.place_of(&attribute.value), + scope, + }), + // `todos[0] = todo`, `table["a"] = 1` + Expr::Subscript(subscript) => matches!( + collector.observable_of(&subscript.value), + Some(ObservableKind::StateList | ObservableKind::StateDict) + ) + .then(|| StateWrite { + range: subscript.range(), + place: collector.place_of(&subscript.value), + scope, + }), + // `count.set(0)`, `todos.append(todo)` + Expr::Call(call) => { + let Type::BoundMethod(method) = collector.type_of(&call.func)? else { + return None; + }; + let kind = observable_kind(db, collector.env(), method.self_instance(db))?; + if !kind.is_mutator(method.function(db).name(db)) { + return None; + } + let place = match call.func.as_ref() { + Expr::Attribute(attribute) => collector.place_of(&attribute.value), + _ => None, + }; + Some(StateWrite { + range: call.func.range(), + place, + scope, + }) + } + _ => None, + } +} + +/// The parameters of the function `scope` is written in — the nearest +/// enclosing `def` that is not a content block — which decide how a name the +/// scope writes is seen: as that function's parameter, a local, or a global. +fn enclosing_parameters<'db>( + index: &SemanticIndex<'db>, + module: &ParsedModuleRef, + scope: FileScopeId, +) -> FxHashMap, ParameterInfo> { + for (_, ancestor) in index.ancestor_scopes(scope) { + match ancestor.node() { + NodeWithScopeKind::Function(function) => { + let function = function.node(module); + if !function.is_trailing_lambda { + return function_parameters(index, function); + } + } + NodeWithScopeKind::Module | NodeWithScopeKind::Class(_) => break, + _ => {} + } + } + FxHashMap::default() +} + +/// whether what `scope` does runs after a composition: in a handler block, a +/// lambda, a nested `def` or an effect of a composition, or in a function +/// outside every composition, which runs when something calls it. Module-level +/// code runs at import, before any composition there is +fn runs_after_composing<'db>( + db: &'db dyn Db, + file: File, + index: &SemanticIndex<'db>, + module: &ParsedModuleRef, + scope: FileScopeId, +) -> bool { + match composition_of_scope(db, file, index, module, scope) { + Some(composition) => composition.runs_after_composing(), + None => index.ancestor_scopes(scope).any(|(_, ancestor)| { + matches!( + ancestor.node(), + NodeWithScopeKind::Function(_) | NodeWithScopeKind::Lambda(_) + ) + }), + } +} + +/// Collects the expressions of one statement, or one lambda body, that are +/// spelled like an observable write: a store to `.value`, a subscript store, +/// a call to a method named like a mutator of some observable. Which of them +/// are writes is decided by their types afterwards. +#[derive(Default)] +struct Candidates<'a> { + found: Vec<&'a Expr>, +} + +impl<'a> Visitor<'a> for Candidates<'a> { + // the statements nested in a compound statement, and the body of a + // nested function or block, are write sites of their own + fn visit_body(&mut self, _body: &'a [Stmt]) {} + + fn visit_expr(&mut self, expr: &'a Expr) { + match expr { + // a lambda body is a write site of its own; its defaults run here + Expr::Lambda(lambda) => { + for default in lambda + .parameters + .iter() + .flat_map(|parameters| parameters.iter_non_variadic_params()) + .filter_map(|parameter| parameter.default.as_deref()) + { + self.visit_expr(default); + } + return; + } + Expr::Attribute(attribute) + if attribute.ctx.is_store() && attribute.attr.as_str() == "value" => + { + self.found.push(expr); + } + Expr::Subscript(subscript) if subscript.ctx.is_store() => self.found.push(expr), + Expr::Call(call) + if matches!( + call.func.as_ref(), + Expr::Attribute(attribute) + if ObservableKind::is_any_mutator(attribute.attr.as_str()) + ) => + { + self.found.push(expr); + } + _ => {} + } + walk_expr(self, expr); + } +} diff --git a/crates/ty_python_semantic/src/types/state_reads.rs b/crates/ty_python_semantic/src/types/state_reads.rs new file mode 100644 index 0000000000..ff3aa0c689 --- /dev/null +++ b/crates/ty_python_semantic/src/types/state_reads.rs @@ -0,0 +1,1129 @@ +//! basedpython-ui: static tracking of the observables a function reads while +//! composing. +//! +//! A `@composable` re-runs whenever an observable it read during its last +//! composition changes. The runtime tracks that set exactly; this module +//! recovers a static approximation of it, so an editor can show a composable's +//! dependencies on its header (`def Counter(step: int = 1) reads count:`) and +//! a `derived(...)` computation's on its line. +//! +//! A *read* is one of: +//! +//! - `.value` on a `State[T]` or `Derived[T]`, `.current` on an `Ambient[T]` +//! - iteration, `len`, a subscript, `in`, or one of the reading methods +//! (`each`, `each_indexed`, `snapshot`, `index_where`) on a `StateList[T]`; +//! a subscript, `in`, `len`, `get`, `keys` or `items` on a `StateDict[K, V]` +//! - a use of a `context` parameter, which the caller fills from its own +//! composition +//! +//! Each read names a *place*: the root definition the expression starts from +//! — a parameter, a local `let`, a module-level name — plus the attribute path +//! from there (`self.model.count`). A read of something with no such place +//! (`state(0).value`) is real but nameless, and is not shown. +//! +//! Only what runs *while composing* counts: the body itself, the `once` +//! content blocks written in it (`Column:`, `Row:`) and the `local` blocks +//! of a keyed `each`, which run before their call returns. A handler block, a +//! lambda, a nested `def` or an effect block runs later, so nothing in one is +//! a read of this composition. +//! +//! Inference is interprocedural, with the same two-phase shape as the +//! exception tracking in `exceptions.rs`: [`body_state_read_effects`] reads a +//! body's own reads and calls off its inferred types, and +//! [`inferred_state_reads`] takes the least fixed point over the call graph. +//! A callee contributes the reads rooted at its parameters, mapped through the +//! arguments the call writes, and the reads of module-level names; its own +//! locals are dropped, since a `state()` created inside a callee is that +//! callee's cell. A callee that cannot be followed — a `dynamic` value — marks +//! the set *opaque*, which the hint shows as `…`. +//! +//! The set is a superset approximation for hints and lints only: invalidation +//! at runtime always uses the exact set, so imprecision here can never cause a +//! missed re-render. + +use ruff_db::files::{File, FileRange}; +use ruff_db::parsed::{ParsedModuleRef, parsed_module}; +use ruff_python_ast::name::Name; +use ruff_python_ast::visitor::{Visitor, walk_expr, walk_stmt}; +use ruff_python_ast::{self as ast, Expr, Stmt}; +use ruff_text_size::{Ranged, TextRange, TextSize}; +use rustc_hash::FxHashMap; +use ty_module_resolver::{KnownModule, file_to_module, resolve_module_confident}; +use ty_python_core::definition::{Definition, DefinitionKind}; +use ty_python_core::scope::{FileScopeId, NodeWithScopeKind, NodeWithScopeRef, ScopeId}; +use ty_python_core::{ProgramFile, SemanticIndex, semantic_index}; + +use crate::types::context_params::implicit_context_arguments; +use crate::types::dedicated::basedpython_ui::{ObservableKind, observable_kind}; +use crate::types::function::{FunctionLiteral, OverloadLiteral}; +use crate::types::infer::ScopeInference; +use crate::types::trailing_lambda::{ + block_callee, callee_callback_is_borrowed, callee_callback_is_once, +}; +use crate::types::{ProgramEnvironment, Type, TypeContext, infer_scope_types}; +use crate::{Db, FxIndexSet}; + +/// the methods of a `StateList` that read it — every one subscribes the +/// composition to the list, exactly as iterating it would +const STATE_LIST_READERS: &[&str] = &[ + "each", + "each_indexed", + "snapshot", + "index_where", + "__len__", + "__iter__", + "__getitem__", + "__contains__", +]; + +/// the methods of a `StateDict` that read it +const STATE_DICT_READERS: &[&str] = &[ + "get", + "keys", + "items", + "__len__", + "__iter__", + "__getitem__", + "__contains__", +]; + +/// Where a state place is rooted, relative to the function whose body was +/// walked. The kind decides what a caller sees of the place. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) enum PlaceRootKind { + /// one of the function's own parameters, which a caller fills: `index` is + /// its position among the positional parameters, `None` for a keyword-only + /// one + Parameter { index: Option }, + /// a name bound in the function's body, in a block written in it, or in a + /// function enclosing it — a cell of this composition, which no caller sees + Local, + /// a module-level name, which every caller shares + Global, +} + +/// The definition a state place starts from. +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) struct PlaceRoot<'db> { + /// the binding the name resolves to — the place's identity + pub(super) definition: Definition<'db>, + pub(super) kind: PlaceRootKind, + /// the name as the source spells it + pub(super) name: Name, + /// the range of the binding's name in the file `definition` is in, for + /// navigation and ordering; recorded while that file is being read, so + /// that a caller in another file never needs its syntax tree + pub(super) declaration: TextRange, +} + +/// One observable a body reads, named by where it starts and the attributes +/// read from there: `count`, or `self.model.count`. +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) struct StatePlace<'db> { + pub(super) root: PlaceRoot<'db>, + pub(super) path: Box<[Name]>, +} + +impl<'db> StatePlace<'db> { + pub(super) fn at_root(root: PlaceRoot<'db>) -> Self { + Self { + root, + path: Box::default(), + } + } + + /// this place with `path` read from it: `model` extended by `count` is + /// `model.count` + pub(super) fn extended(&self, path: &[Name]) -> Self { + Self { + root: self.root.clone(), + path: self.path.iter().chain(path).cloned().collect(), + } + } + + /// whether this place and `other` name the same observable: the same + /// binding, read through the same attributes. The root's kind is left out + /// — it says how the body that read the place sees the binding, not which + /// binding it is — so a composable's parameter and a nested function's + /// capture of it compare equal + pub(super) fn same_place(&self, other: &Self) -> bool { + self.root.definition == other.root.definition && self.path == other.path + } + + /// the place as it is written: the root name and the attribute path + pub(crate) fn display_name(&self) -> String { + let mut name = self.root.name.to_string(); + for attribute in &self.path { + name.push('.'); + name.push_str(attribute); + } + name + } + + /// where the place's root is declared + pub(crate) fn declaration(&self, db: &'db dyn Db) -> FileRange { + FileRange::new(self.root.definition.file(db), self.root.declaration) + } + + /// the key the places of one function are ordered by: declaration order, + /// with the reads of another file's globals after this file's + fn order_key(&self, db: &'db dyn Db, file: File) -> (bool, TextSize, &[Name]) { + ( + self.root.definition.file(db) != file, + self.root.declaration.start(), + &self.path, + ) + } +} + +/// One call in a body, with the places its written arguments name, so that +/// the callee's parameter-rooted reads can be mapped back onto the caller. +#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) struct CallEffect<'db> { + /// the called function, whose own reads are resolved separately + pub(super) callee: FunctionLiteral<'db>, + /// whether the call carried a trailing block — `Card(count):`, a bare + /// `Row:`. The runtime runs such a callee *inline*: it re-runs whenever + /// its caller does, and what it reads subscribes the caller's scope + /// rather than one of its own + pub(super) inline: bool, + /// whether the callee's first parameter is filled by something other than + /// the written arguments: the receiver of a bound method, or the class a + /// classmethod is called on + bound: bool, + /// the place a bound method's receiver names, when it names one + receiver: Option>, + /// the place each positional argument names, up to the first unpacked one + positional: Box<[Option>]>, + /// the place each keyword argument names, the implicit `context` + /// arguments the call fills included + keywords: Box<[(Name, Option>)]>, + /// the arguments the call unpacks, which fill parameters the written + /// arguments do not name + unpacked: Unpacked, +} + +/// What a call unpacks into its arguments: `Child(*cells)`, `Child(**options)`. +/// What an unpacked argument fills is not knowable statically, so a parameter +/// it may land on holds something the walk cannot see into. +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Hash, get_size2::GetSize, salsa::SalsaValue, +)] +struct Unpacked { + /// `*cells`: it, and every positional argument written after it, land on + /// the positional parameters the arguments before it left unfilled + positional: bool, + /// `**options`, which can fill any parameter + keywords: bool, +} + +impl<'db> CallEffect<'db> { + /// the place the argument filling the callee's parameter `name` (at + /// positional `index`) names, if the call writes one that names a place + pub(super) fn argument(&self, index: Option, name: &Name) -> Option<&StatePlace<'db>> { + if let Some((_, place)) = self.keywords.iter().find(|(keyword, _)| keyword == name) { + return place.as_ref(); + } + let index = index?; + if self.bound { + if index == 0 { + return self.receiver.as_ref(); + } + self.positional.get(index - 1)?.as_ref() + } else { + self.positional.get(index)?.as_ref() + } + } + + /// whether the callee's parameter `name` (at positional `index`) may be + /// filled by an unpacked argument: nothing written fills it, and the call + /// unpacks something that can. What such a parameter holds is unknown, + /// so a read through it, or a slot handed through it, cannot be seen + pub(super) fn may_fill_unseen(&self, index: Option, name: &Name) -> bool { + if self.keywords.iter().any(|(keyword, _)| keyword == name) { + return false; + } + let written_positionally = index.is_some_and(|index| { + if self.bound { + index == 0 || self.positional.len() >= index + } else { + self.positional.len() > index + } + }); + if written_positionally { + return false; + } + self.unpacked.keywords || (index.is_some() && self.unpacked.positional) + } +} + +/// What a function body reads and calls while composing, with its callees +/// left unresolved. +/// +/// Splitting the analysis here is what keeps the recursion cheap and safe: +/// collecting the effects reads the function's own inferred expression types, +/// while [`resolve_state_reads`] walks the call graph over effects alone and +/// never re-enters type inference. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Default, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) struct StateReadEffects<'db> { + /// the places read directly in this body, in first-read order + pub(super) reads: Box<[StatePlace<'db>]>, + /// the calls made while composing + pub(super) calls: Box<[CallEffect<'db>]>, + /// whether something was called that cannot be followed + pub(super) opaque: bool, +} + +impl StateReadEffects<'_> { + /// Whether this body can read nothing at all, without resolving any callee. + pub(crate) fn is_empty(&self) -> bool { + self.reads.is_empty() && self.calls.is_empty() && !self.opaque + } +} + +/// The observables a function reads while composing, its callees followed. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Default, get_size2::GetSize, salsa::SalsaValue)] +pub(crate) struct StateReads<'db> { + /// the places read, in declaration order of their roots + pub(crate) places: Box<[StatePlace<'db>]>, + /// whether a callee could not be followed, so the set may be missing + /// something + pub(crate) opaque: bool, +} + +impl<'db> StateReads<'db> { + pub(super) fn new( + db: &'db dyn Db, + file: File, + places: FxIndexSet>, + opaque: bool, + ) -> Self { + let mut places = places.into_iter().collect::>(); + places.sort_by(|left, right| left.order_key(db, file).cmp(&right.order_key(db, file))); + Self { + places: places.into_boxed_slice(), + opaque, + } + } + + /// Whether nothing is read and every callee was followed. + pub(crate) fn is_empty(&self) -> bool { + self.places.is_empty() && !self.opaque + } +} + +/// [`StateReadEffects`] for `overload`'s body, read off its own inferred types. +#[salsa::tracked( + returns(ref), + cycle_initial = |_, _, _| StateReadEffects::default(), + heap_size = ruff_memory_usage::heap_size, +)] +pub(crate) fn body_state_read_effects<'db>( + db: &'db dyn Db, + overload: OverloadLiteral<'db>, +) -> StateReadEffects<'db> { + let file = overload.file(db); + if !file.source_type(db).is_basedpython() { + return StateReadEffects::default(); + } + let program_file = overload.program_file(db); + let env = ProgramEnvironment::from_file(program_file); + // there is nothing to read unless the framework resolves in this program + if resolve_module_confident( + db, + env.resolver_environment(db), + &KnownModule::BasedpythonUiRuntime.name(), + ) + .is_none() + { + return StateReadEffects::default(); + } + + let module = parsed_module(db, program_file.python_file(db)).load(db); + let index = semantic_index(db, program_file); + let node = overload.node(db, file, &module); + let mut collector = ReadsCollector::new( + db, + env, + program_file, + index, + &module, + function_parameters(index, node), + overload.body_scope(db).file_scope_id(db), + ); + collector.visit_body(&node.body); + collector.finish() +} + +/// The observables `overload` reads while composing, its callees followed. +#[salsa::tracked( + returns(ref), + cycle_initial = |_, _, _| StateReads::default(), + heap_size = ruff_memory_usage::heap_size, +)] +pub(crate) fn inferred_state_reads<'db>( + db: &'db dyn Db, + overload: OverloadLiteral<'db>, +) -> StateReads<'db> { + resolve_state_reads( + db, + overload.file(db), + body_state_read_effects(db, overload), + Some(overload.body_scope(db)), + ) +} + +/// The observables a call to `function` reads: the union over its overloads +/// and implementation, since which overload a call matched is not known here. +pub(crate) fn function_state_reads<'db>( + db: &'db dyn Db, + function: FunctionLiteral<'db>, +) -> StateReads<'db> { + let mut places = FxIndexSet::default(); + let mut opaque = false; + for overload in function.iter_overloads_and_implementation(db) { + let reads = inferred_state_reads(db, overload); + places.extend(reads.places.iter().cloned()); + opaque |= reads.opaque; + } + StateReads::new(db, function.last_definition.file(db), places, opaque) +} + +/// The observables a lambda reads while composing — what a `derived(lambda: +/// ...)` depends on. The lambda's closure is its enclosing function, so a +/// local of that function is a dependency here, not a dropped cell. +pub(crate) fn lambda_state_reads<'db>( + db: &'db dyn Db, + program_file: ProgramFile<'db>, + index: &SemanticIndex<'db>, + module: &ParsedModuleRef, + lambda: &ast::ExprLambda, +) -> StateReads<'db> { + let env = ProgramEnvironment::from_file(program_file); + let scope = index.node_scope(NodeWithScopeRef::Lambda(lambda)); + // the function the lambda is written in, whose `context` parameters are + // reads when the lambda uses them + let parameters = index + .ancestor_scopes(scope) + .skip(1) + .find_map(|(_, ancestor)| match ancestor.node() { + NodeWithScopeKind::Function(function) => Some(function.node(module)), + NodeWithScopeKind::Module | NodeWithScopeKind::Class(_) => None, + _ => None, + }) + .map_or_else(FxHashMap::default, |function| { + function_parameters(index, function) + }); + let mut collector = + ReadsCollector::new(db, env, program_file, index, module, parameters, scope); + collector.visit_expr(&lambda.body); + let effects = collector.finish(); + resolve_state_reads(db, program_file.file(db), &effects, None) +} + +/// Union the reads of `effects`, following each call into its callee. +/// +/// `self_body_scope` is the scope of the function the effects belong to, when +/// it is known: a directly recursive call contributes exactly the set being +/// computed, so it is the identity of the union and can be dropped rather than +/// re-entered. +fn resolve_state_reads<'db>( + db: &'db dyn Db, + file: File, + effects: &StateReadEffects<'db>, + self_body_scope: Option>, +) -> StateReads<'db> { + let mut places: FxIndexSet> = effects.reads.iter().cloned().collect(); + let mut opaque = effects.opaque; + + for call in &effects.calls { + if call + .callee + .iter_overloads_and_implementation(db) + .any(|overload| Some(overload.body_scope(db)) == self_body_scope) + { + continue; + } + let callee = function_state_reads(db, call.callee); + opaque |= callee.opaque; + for place in &callee.places { + match place.root.kind { + PlaceRootKind::Global => { + places.insert(place.clone()); + } + // a callee's own cell is the callee's, not the caller's + PlaceRootKind::Local => {} + PlaceRootKind::Parameter { index } => { + if let Some(argument) = call.argument(index, &place.root.name) { + places.insert(argument.extended(&place.path)); + } else if call.may_fill_unseen(index, &place.root.name) { + // the callee reads a parameter an unpacked argument + // fills, from something the walk cannot see into + opaque = true; + } + } + } + } + } + + StateReads::new(db, file, places, opaque) +} + +/// What is known of one parameter of the function whose body is walked. +#[derive(Clone, Copy)] +pub(super) struct ParameterInfo { + /// its position among the positional parameters, `None` for a keyword-only + /// one + pub(super) index: Option, + /// whether it is a `context` parameter, whose use is a read + is_context: bool, +} + +/// The parameters of `function`, by their definitions. +pub(super) fn function_parameters<'db>( + index: &SemanticIndex<'db>, + function: &ast::StmtFunctionDef, +) -> FxHashMap, ParameterInfo> { + parameter_definitions(index, function) + .map(|(position, parameter, definition)| { + ( + definition, + ParameterInfo { + index: position, + is_context: parameter.is_context, + }, + ) + }) + .collect() +} + +/// The parameters of `function` a call can fill by position or by keyword — +/// every one but the variadic pair — each with its position among the +/// positional parameters (`None` for a keyword-only one), its node and its +/// definition. +pub(super) fn parameter_definitions<'a, 'db>( + index: &'a SemanticIndex<'db>, + function: &'a ast::StmtFunctionDef, +) -> impl Iterator, &'a ast::Parameter, Definition<'db>)> + 'a { + let parameters = &function.parameters; + let positional = parameters + .posonlyargs + .iter() + .chain(¶meters.args) + .enumerate() + .map(|(position, parameter)| (Some(position), ¶meter.parameter)); + let keyword_only = parameters + .kwonlyargs + .iter() + .map(|parameter| (None, ¶meter.parameter)); + positional + .chain(keyword_only) + .filter_map(move |(position, parameter)| { + Some((position, parameter, index.try_definition(parameter)?)) + }) +} + +/// Collects the [`StateReadEffects`] of a body. +pub(super) struct ReadsCollector<'a, 'db> { + db: &'db dyn Db, + env: ProgramEnvironment<'db>, + program_file: ProgramFile<'db>, + index: &'a SemanticIndex<'db>, + module: &'a ParsedModuleRef, + /// the scopes enclosing what is being visited, innermost last, each with + /// its own inferred types: a content block and a comprehension are scopes + /// of their own, run while composing + scopes: Vec<(FileScopeId, &'db ScopeInference<'db>)>, + /// the parameters of the function the effects are for + parameters: FxHashMap, ParameterInfo>, + /// whether any of those is a `context` parameter, so that a name load is + /// worth resolving + has_context_parameters: bool, + /// the call a trailing-lambda block is attached to, while its expression + /// is being visited: that call, and no call nested in its arguments, is + /// the one that carries the block + block_call: Option, + reads: FxIndexSet>, + calls: Vec>, + opaque: bool, +} + +impl<'a, 'db> ReadsCollector<'a, 'db> { + pub(super) fn new( + db: &'db dyn Db, + env: ProgramEnvironment<'db>, + program_file: ProgramFile<'db>, + index: &'a SemanticIndex<'db>, + module: &'a ParsedModuleRef, + parameters: FxHashMap, ParameterInfo>, + scope: FileScopeId, + ) -> Self { + let has_context_parameters = parameters.values().any(|parameter| parameter.is_context); + let mut collector = Self { + db, + env, + program_file, + index, + module, + scopes: Vec::new(), + parameters, + has_context_parameters, + block_call: None, + reads: FxIndexSet::default(), + calls: Vec::new(), + opaque: false, + }; + collector.push_scope(scope); + collector + } + + pub(super) fn finish(self) -> StateReadEffects<'db> { + StateReadEffects { + reads: self.reads.into_iter().collect(), + calls: self.calls.into_boxed_slice(), + opaque: self.opaque, + } + } + + fn push_scope(&mut self, scope: FileScopeId) { + let inference = infer_scope_types( + self.db, + scope.to_scope_id(self.db, self.program_file), + TypeContext::default(), + ); + self.scopes.push((scope, inference)); + } + + fn pop_scope(&mut self) { + self.scopes.pop(); + } + + /// the scope the expression being visited is in + fn scope(&self) -> Option { + self.scopes.last().map(|(scope, _)| *scope) + } + + pub(super) fn env(&self) -> &ProgramEnvironment<'db> { + &self.env + } + + pub(super) fn type_of(&self, expr: &Expr) -> Option> { + self.scopes + .last() + .and_then(|(_, inference)| inference.try_expression_type(expr)) + } + + pub(super) fn observable_of(&self, expr: &Expr) -> Option { + observable_kind(self.db, &self.env, self.type_of(expr)?) + } + + /// the binding a bare `name` resolves to from the current scope + fn definition_of_name(&self, name: &str) -> Option> { + root_definition(self.db, self.index, self.module, self.scope()?, name) + } + + /// `definition`, which `name` resolves to, as a place root: how the body + /// being walked sees the binding + fn root_of(&self, definition: Definition<'db>, name: &str) -> PlaceRoot<'db> { + let db = self.db; + let kind = match self.parameters.get(&definition) { + Some(parameter) => PlaceRootKind::Parameter { + index: parameter.index, + }, + None if definition.file_scope(db).is_global() => PlaceRootKind::Global, + None => PlaceRootKind::Local, + }; + PlaceRoot { + definition, + kind, + name: Name::new(name), + declaration: definition.focus_range(db, self.module).range(), + } + } + + /// the place `expr` names, when it is a name or an attribute chain from + /// one, read from the current scope + pub(super) fn place_of(&self, expr: &Expr) -> Option> { + self.place_in(self.scope()?, expr, &mut Vec::new()) + } + + /// the place the bare `name` names, read from the current scope + fn place_of_name(&self, name: &str) -> Option> { + self.name_place(self.scope()?, name, &mut Vec::new()) + } + + /// The place `expr` names when read from `scope`. + /// + /// A name bound to another place — `let alias = count`, `let cell = + /// model.count` — is an alias of it, not an observable of its own: the + /// runtime sees one cell however many names it goes by, so the alias is + /// followed to what it was bound to, binding by binding, in the scope each + /// binding was made in. `visited` guards against two names bound to each + /// other; such a chain names nothing. + fn place_in( + &self, + scope: FileScopeId, + expr: &Expr, + visited: &mut Vec>, + ) -> Option> { + match expr { + Expr::Name(name) => self.name_place(scope, name.id.as_str(), visited), + Expr::Attribute(attribute) => Some( + self.place_in(scope, &attribute.value, visited)? + .extended(std::slice::from_ref(&attribute.attr.id)), + ), + _ => None, + } + } + + /// [`Self::place_in`] for the bare `name`. + fn name_place( + &self, + scope: FileScopeId, + name: &str, + visited: &mut Vec>, + ) -> Option> { + let db = self.db; + let definition = root_definition(db, self.index, self.module, scope, name)?; + if let Some(value) = alias_value(db, self.module, definition) { + if visited.contains(&definition) { + return None; + } + visited.push(definition); + return self.place_in(definition.file_scope(db), value, visited); + } + Some(StatePlace::at_root(self.root_of(definition, name))) + } + + /// Record a read of the observable `expr` names. + fn record_read(&mut self, expr: &Expr) { + if let Some(place) = self.place_of(expr) { + self.reads.insert(place); + } + } + + /// Record `expr` as read when it is a collection observable in a position + /// that iterates or measures it. + fn record_collection_read(&mut self, expr: &Expr) { + if matches!( + self.observable_of(expr), + Some(ObservableKind::StateList | ObservableKind::StateDict) + ) { + self.record_read(expr); + } + } + + /// Record a load of `name` when it is a `context` parameter of the + /// function: the caller fills it from its own composition, so a use of it + /// is a dependency on what the caller provides. + fn record_context_parameter(&mut self, name: &ast::ExprName) { + if !self.has_context_parameters { + return; + } + let Some(definition) = self.definition_of_name(name.id.as_str()) else { + return; + }; + if self + .parameters + .get(&definition) + .is_some_and(|parameter| parameter.is_context) + { + self.reads.insert(StatePlace::at_root( + self.root_of(definition, name.id.as_str()), + )); + } + } + + /// Record a call to `callee`, with the places its arguments name. + /// + /// `callee_expr` is the expression called, whose receiver a bound method + /// reads; `call` is the call node, `None` for a bare block callee (`Row:`), + /// whose only argument is the block. + fn record_call(&mut self, callee: Type<'db>, callee_expr: &Expr, call: Option<&ast::ExprCall>) { + let db = self.db; + let (function, bound) = match callee { + Type::FunctionLiteral(function) => (function, false), + Type::BoundMethod(method) => { + let function = method.function(db); + (function, !function.is_staticmethod(db)) + } + // what a `dynamic` value does when called cannot be seen + Type::Dynamic(_) => { + self.opaque = true; + return; + } + _ => return, + }; + + let receiver = match callee_expr { + Expr::Attribute(attribute) if bound && !function.is_classmethod(db) => { + self.place_of(&attribute.value) + } + _ => None, + }; + + // a bare block callee (`Row:`) is recorded with no call node, and is + // always the block's call + let inline = call.is_none_or(|call| self.block_call == Some(call.range())); + + let mut positional = Vec::new(); + let mut keywords = Vec::new(); + let mut unpacked = Unpacked::default(); + if let Some(call) = call { + for argument in &call.arguments.args { + if argument.is_starred_expr() { + unpacked.positional = true; + break; + } + positional.push(self.place_of(argument)); + } + for keyword in &call.arguments.keywords { + match &keyword.arg { + Some(name) => keywords.push((name.id.clone(), self.place_of(&keyword.value))), + None => unpacked.keywords = true, + } + } + // a `context` parameter the call leaves unmatched is filled from a + // name in scope here, exactly as if the call had written it + for implicit in + implicit_context_arguments(db, &self.env, self.program_file.file(db), callee, call) + { + if implicit.is_block_receiver { + continue; + } + let place = self.place_of_name(implicit.variable.as_str()); + keywords.push((implicit.parameter, place)); + } + } + + self.calls.push(CallEffect { + callee: function.literal(db), + inline, + bound, + receiver, + positional: positional.into_boxed_slice(), + keywords: keywords.into_boxed_slice(), + unpacked, + }); + } + + /// A call: the reading methods of a collection observable, what a builtin + /// iterates, and the callee itself. + fn visit_call(&mut self, call: &ast::ExprCall) { + if let Expr::Attribute(attribute) = call.func.as_ref() { + let readers: &[&str] = match self.observable_of(&attribute.value) { + Some(ObservableKind::StateList) => STATE_LIST_READERS, + Some(ObservableKind::StateDict) => STATE_DICT_READERS, + _ => &[], + }; + if readers.contains(&attribute.attr.as_str()) { + self.record_read(&attribute.value); + } + } + + let Some(callee) = self.type_of(&call.func) else { + return; + }; + // `len(todos)`, `list(todos)`, `sum(todos)`: a builtin handed a + // collection observable reads it — a superset that costs at most a + // spurious name, never a missed one + if is_builtin_callee(self.db, callee) { + for argument in &call.arguments.args { + self.record_collection_read(argument); + } + for keyword in &call.arguments.keywords { + self.record_collection_read(&keyword.value); + } + } + self.record_call(callee, &call.func, Some(call)); + } + + /// A trailing-lambda block: the call it makes belongs to this scope, and + /// its body runs while composing only when the callee takes the block as + /// a `once` or `local` callback. + fn visit_block(&mut self, block: &ast::StmtFunctionDef) { + for decorator in &block.decorator_list { + let expression = match &decorator.expression { + Expr::Await(await_expr) => await_expr.value.as_ref(), + expression => expression, + }; + // a bare `Row:` calls its callee with the block alone; a written + // call is recorded when its expression is visited, and knows it + // carries the block from `block_call` + if !expression.is_call_expr() + && let Some(callee) = self.type_of(expression) + { + self.record_call(callee, expression, None); + } + let outer = self.block_call.replace(expression.range()); + self.visit_expr(expression); + self.block_call = outer; + } + + let Some(callee) = block_callee(self.db, self.index, block) else { + return; + }; + if callee_callback_is_once(self.db, callee.ty) + || callee_callback_is_borrowed(self.db, callee.ty) == Some(true) + { + self.push_scope(self.index.node_scope(NodeWithScopeRef::Function(block))); + self.visit_body(&block.body); + self.pop_scope(); + } + } + + /// A comprehension: its first iterable is evaluated in the enclosing + /// scope, everything else in a scope of its own — which still runs while + /// composing. + fn visit_comprehension( + &mut self, + scope: NodeWithScopeRef<'_>, + elements: &[&Expr], + generators: &[ast::Comprehension], + ) { + let Some((first, rest)) = generators.split_first() else { + return; + }; + self.visit_expr(&first.iter); + self.record_collection_read(&first.iter); + + self.push_scope(self.index.node_scope(scope)); + self.visit_expr(&first.target); + for condition in &first.ifs { + self.visit_expr(condition); + } + for generator in rest { + self.visit_expr(&generator.iter); + self.record_collection_read(&generator.iter); + self.visit_expr(&generator.target); + for condition in &generator.ifs { + self.visit_expr(condition); + } + } + for element in elements { + self.visit_expr(element); + } + self.pop_scope(); + } +} + +impl<'ast> Visitor<'ast> for ReadsCollector<'_, '_> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + match stmt { + Stmt::FunctionDef(block) if block.is_trailing_lambda => self.visit_block(block), + + // a nested function runs when something calls it, not here; its + // decorators and defaults do run here + Stmt::FunctionDef(function) => { + for decorator in &function.decorator_list { + self.visit_expr(&decorator.expression); + } + for default in function + .parameters + .iter_non_variadic_params() + .filter_map(|parameter| parameter.default.as_deref()) + { + self.visit_expr(default); + } + } + + // a class body is a scope of its own, and nothing composes in one + Stmt::ClassDef(class) => { + for decorator in &class.decorator_list { + self.visit_expr(&decorator.expression); + } + for base in class.bases() { + self.visit_expr(base); + } + for keyword in class.keywords() { + self.visit_expr(&keyword.value); + } + } + + Stmt::For(for_stmt) => { + self.visit_expr(&for_stmt.iter); + self.record_collection_read(&for_stmt.iter); + self.visit_expr(&for_stmt.target); + self.visit_body(&for_stmt.body); + self.visit_body(&for_stmt.orelse); + } + + _ => walk_stmt(self, stmt), + } + } + + fn visit_expr(&mut self, expr: &'ast Expr) { + match expr { + // a lambda body runs when something calls it; its defaults run here + Expr::Lambda(lambda) => { + for default in lambda + .parameters + .iter() + .flat_map(|parameters| parameters.iter_non_variadic_params()) + .filter_map(|parameter| parameter.default.as_deref()) + { + self.visit_expr(default); + } + return; + } + + Expr::ListComp(comprehension) => { + self.visit_comprehension( + NodeWithScopeRef::ListComprehension(comprehension), + &[&comprehension.elt], + &comprehension.generators, + ); + return; + } + Expr::SetComp(comprehension) => { + self.visit_comprehension( + NodeWithScopeRef::SetComprehension(comprehension), + &[&comprehension.elt], + &comprehension.generators, + ); + return; + } + Expr::DictComp(comprehension) => { + let elements: Vec<&Expr> = comprehension + .key + .iter() + .map(AsRef::as_ref) + .chain([comprehension.value.as_ref()]) + .collect(); + self.visit_comprehension( + NodeWithScopeRef::DictComprehension(comprehension), + &elements, + &comprehension.generators, + ); + return; + } + Expr::Generator(generator) => { + self.visit_comprehension( + NodeWithScopeRef::GeneratorExpression(generator), + &[&generator.elt], + &generator.generators, + ); + return; + } + + // `count.value`, `theme.current` + Expr::Attribute(attribute) if attribute.ctx.is_load() => { + let read = matches!( + ( + self.observable_of(&attribute.value), + attribute.attr.as_str(), + ), + ( + Some(ObservableKind::State | ObservableKind::Derived), + "value" + ) | (Some(ObservableKind::Ambient), "current") + ); + if read { + self.record_read(&attribute.value); + } + } + + // `todos[0]`, `table["a"]` + Expr::Subscript(subscript) if subscript.ctx.is_load() => { + self.record_collection_read(&subscript.value); + } + + Expr::Call(call) => self.visit_call(call), + + // `key in table` + Expr::Compare(compare) => { + for (op, comparator) in compare.ops.iter().zip(&compare.comparators) { + if matches!(op, ast::CmpOp::In | ast::CmpOp::NotIn) { + self.record_collection_read(comparator); + } + } + } + + // `f(*todos)`, `(*todos,)` + Expr::Starred(starred) => self.record_collection_read(&starred.value), + + Expr::Name(name) if name.ctx.is_load() => self.record_context_parameter(name), + + _ => {} + } + + walk_expr(self, expr); + } +} + +/// The binding a bare `name` resolves to from `scope`: the first binding of +/// the innermost enclosing scope that binds it, following `global` and +/// `nonlocal` declarations. `None` for a name nothing in the file binds — a +/// builtin, or a member a block's receiver supplies. +fn root_definition<'db>( + db: &'db dyn Db, + index: &SemanticIndex<'db>, + module: &ParsedModuleRef, + scope: FileScopeId, + name: &str, +) -> Option> { + let first_binding = |scope: FileScopeId| { + let symbol_id = index.place_table(scope).symbol_id(name)?; + index + .use_def_map(scope) + .reachable_symbol_bindings(symbol_id) + .filter_map(|binding| binding.binding.definition()) + .min_by_key(|definition| definition.focus_range(db, module).range().start()) + }; + + for (scope_id, _) in index.visible_ancestor_scopes(scope) { + let table = index.place_table(scope_id); + let Some(symbol_id) = table.symbol_id(name) else { + continue; + }; + let symbol = table.symbol(symbol_id); + if symbol.is_global() { + return first_binding(FileScopeId::global()); + } + if symbol.is_nonlocal() || !symbol.is_bound() { + continue; + } + if let Some(definition) = first_binding(scope_id) { + return Some(definition); + } + } + None +} + +/// The place a binding stands for when it binds its name to another place +/// rather than to a value of its own: the `count` of `let alias = count`, the +/// `model.count` of `let cell = model.count`. `None` for every other binding +/// — a call's result, a parameter, a loop target, an unpacking. +/// +/// The expression is to be read in the scope `definition` is bound in. +fn alias_value<'ast, 'db>( + db: &'db dyn Db, + module: &'ast ParsedModuleRef, + definition: Definition<'db>, +) -> Option<&'ast Expr> { + let value = match definition.kind(db) { + DefinitionKind::Assignment(assignment) => { + // `a, b = pair` binds each name to an element the statement never + // spells on its own + if assignment.unpack().is_some() { + return None; + } + assignment.value(module) + } + DefinitionKind::AnnotatedAssignment(assignment) => assignment.value(module)?, + _ => return None, + }; + matches!(value, Expr::Name(_) | Expr::Attribute(_)).then_some(value) +} + +/// Whether `callee` is a function or class of the `builtins` module — +/// something that iterates or measures whatever collection it is handed. +fn is_builtin_callee<'db>(db: &'db dyn Db, callee: Type<'db>) -> bool { + let program_file = match callee { + Type::FunctionLiteral(function) => function.program_file(db), + Type::ClassLiteral(class) => class.program_file(db), + _ => return false, + }; + file_to_module(db, program_file.resolver_file(db)).and_then(|module| module.known(db)) + == Some(KnownModule::Builtins) +} diff --git a/crates/ty_python_semantic/src/types/trailing_lambda.rs b/crates/ty_python_semantic/src/types/trailing_lambda.rs index 101eb8db14..4c76f35ad3 100644 --- a/crates/ty_python_semantic/src/types/trailing_lambda.rs +++ b/crates/ty_python_semantic/src/types/trailing_lambda.rs @@ -8,27 +8,157 @@ //! parameter's declared callable type use ruff_db::parsed::parsed_module; -use ruff_python_ast::ParameterBorrow; use ruff_python_ast::name::Name; +use ruff_python_ast::{self as ast, ParameterBorrow}; use ty_python_core::scope::{ScopeId, ScopeKind}; -use ty_python_core::semantic_index; +use ty_python_core::{SemanticIndex, semantic_index}; use crate::Db; -use crate::types::signatures::{Parameter, Signature}; +use crate::types::call::{Argument, CallArguments}; +use crate::types::constraints::ConstraintSetBuilder; +use crate::types::generics::Specialization; +use crate::types::signatures::{Parameter, Parameters, Signature}; use crate::types::soundness::single_signature; -use crate::types::{Type, TypeContext, infer_expression_types}; +use crate::types::{ProgramEnvironment, Type, TypeContext, infer_expression_types}; -/// the type of the expression the trailing lambda block whose body `scope` is in -/// is attached to -pub(crate) fn enclosing_block_callee_type<'db>( +/// a trailing lambda block's callee, together with what the block's call +/// solves for it. +/// +/// A generic free function's callback parameter mentions the function's own +/// type variables — `def each[T](items: tuple[T, ...], block: (T) -> None)` — +/// so what the block's `it` (and receiver) are is only known once the written +/// arguments have solved them. A bound method carries its receiver's +/// specialization in its signature already; a free function's is solved here, +/// from the block's own call, so `it` in `each(("a", "b")):` is `str` rather +/// than `T@each` +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, salsa::SalsaValue, get_size2::GetSize)] +pub(crate) struct BlockCallee<'db> { + /// the type of the expression the block is attached to + pub(crate) ty: Type<'db>, + /// the specialization the call's written arguments solve for a generic + /// callee. `None` for a non-generic one, and when the call cannot be bound + /// from what is written (an unpacked argument, an uninspectable callee) + pub(crate) specialization: Option>, +} + +impl<'db> BlockCallee<'db> { + /// a callee with nothing solved — all a callee reached without its call is + pub(crate) fn unspecialized(ty: Type<'db>) -> Self { + Self { + ty, + specialization: None, + } + } +} + +/// the callee of the trailing lambda block `function`, with the specialization +/// its call solves. Reads the callee and the written arguments from their +/// standalone inferences (registered by the semantic index builder), so it can +/// be asked from inside the block's own scope without a cycle through the +/// enclosing definition's inference. +/// +/// One block is asked for its callee many times over — for `it`, for the +/// receiver, for the borrow, for the callback's return type, and again by each +/// of the composition checks — and solving a generic callee re-binds the whole +/// call every time. Memoising that on the block's scope looks like the obvious +/// win, and is not available: this is deliberately re-entrant. Inferring the +/// block's own scope can need its callback's return type, which asks for the +/// callee again, and a salsa query would turn that recomputation into a +/// dependency-graph cycle rather than a repeat. Recomputing is what keeps it +/// safe. Anything cached here has to be keyed on something the block's own +/// inference cannot reach +pub(crate) fn block_callee<'db>( + db: &'db dyn Db, + index: &SemanticIndex<'db>, + function: &ast::StmtFunctionDef, +) -> Option> { + let callee = function.trailing_lambda_callee()?; + let expression = index.try_expression(callee)?; + let ty = infer_expression_types(db, expression, TypeContext::default()) + .try_expression_type(callee)?; + let env = ProgramEnvironment::from_program(expression.program(db)); + let specialization = block_call_specialization(db, &env, index, function, ty); + Some(BlockCallee { ty, specialization }) +} + +/// what the written arguments of `function`'s call solve for a generic `callee`: +/// the call is bound exactly as the checker binds it in the enclosing scope — +/// the written arguments, then the block as a gradual callable in the callback's +/// position — and the single binding's specialization is read back. Binding +/// errors are not reported here (the enclosing scope does that); a partial +/// solution is still a solution for the parameters it covers +fn block_call_specialization<'db>( + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + index: &SemanticIndex<'db>, + function: &ast::StmtFunctionDef, + callee: Type<'db>, +) -> Option> { + // only a callee with type variables of its own has anything to solve + single_signature(db, callee)?.generic_context?; + let call = function.trailing_lambda_call()?; + let mut items: Vec<(Argument<'_>, Option>)> = Vec::new(); + for argument in call.arguments.iter_source_order() { + let (argument, value) = match argument { + ast::ArgOrKeyword::Arg(value) => { + if value.is_starred_expr() { + return None; + } + (Argument::Positional, value) + } + ast::ArgOrKeyword::Keyword(keyword) => { + (Argument::Keyword(keyword.arg.as_ref()?), &keyword.value) + } + }; + let expression = index.try_expression(value)?; + let ty = infer_expression_types(db, expression, TypeContext::default()) + .try_expression_type(value)?; + items.push((argument, Some(ty))); + } + let keyword = trailing_lambda_keyword(db, callee); + let block_ty = Type::single_callable( + db, + Signature::new(Parameters::gradual_form(), Type::unknown()), + ); + items.push(( + match &keyword { + Some(name) => Argument::Keyword(name), + None => Argument::Positional, + }, + Some(block_ty), + )); + let arguments: CallArguments<'_, 'db> = items.into_iter().collect(); + let constraints = ConstraintSetBuilder::new(); + let bindings = match callee + .bindings(db, env) + .match_parameters(db, env, &arguments) + .check_types( + db, + env, + &constraints, + &arguments, + TypeContext::default(), + &[], + ) { + Ok(bindings) => bindings, + Err(error) => *error.into_bindings(), + }; + let [binding] = bindings.single_element()?.overloads() else { + return None; + }; + binding.merged_specialization(db, env) +} + +/// the callee of the trailing lambda block whose body `scope` is in +pub(crate) fn enclosing_block_callee<'db>( db: &'db dyn Db, scope: ScopeId<'db>, -) -> Option> { +) -> Option> { Some(enclosing_block(db, scope)?.1) } /// the trailing lambda block whose body `scope` is in: the block's own scope, -/// and the type of the expression it is attached to. Walks out through +/// and its callee, specialized by the block's call. Walks out through /// comprehension scopes (which a block body may open) but stops at the first /// function, class, or module scope: a nested definition is its own body, not /// the block's. @@ -57,7 +187,7 @@ pub(crate) fn enclosing_block_callee_type<'db>( pub(crate) fn enclosing_block<'db>( db: &'db dyn Db, scope: ScopeId<'db>, -) -> Option<(ScopeId<'db>, Type<'db>)> { +) -> Option<(ScopeId<'db>, BlockCallee<'db>)> { let program_file = db.program_file(scope.file(db)); let index = semantic_index(db, program_file); for (ancestor_id, ancestor) in index.visible_ancestor_scopes(scope.file_scope_id(db)) { @@ -71,11 +201,8 @@ pub(crate) fn enclosing_block<'db>( if !function.is_trailing_lambda { return None; } - let callee = function.trailing_lambda_callee()?; - let expression = index.try_expression(callee)?; - let callee_ty = infer_expression_types(db, expression, TypeContext::default()) - .try_expression_type(callee)?; - return Some((ancestor_id.to_scope_id(db, program_file), callee_ty)); + let callee = block_callee(db, index, function)?; + return Some((ancestor_id.to_scope_id(db, program_file), callee)); } None } @@ -121,6 +248,16 @@ pub(crate) fn callee_callback_is_borrowed<'db>(db: &'db dyn Db, callee: Type<'db ) } +/// basedpython: whether a call to `callee` can carry a trailing block at all: +/// its last declared parameter is a callable, which is what a block binds. +/// What the callee then makes of the block — `once`, `local`, or retained — +/// is a separate question; the framework's runtime runs a composable called +/// with a block *inline* whichever it is. +pub(crate) fn callee_accepts_block<'db>(db: &'db dyn Db, callee: Type<'db>) -> bool { + last_parameter(db, callee) + .is_some_and(|parameter| matches!(parameter.annotated_type(), Type::Callable(_))) +} + /// the callee's last declared parameter, when the callee has a single /// inspectable signature and that parameter is a plain (non-variadic) one. /// `None` for overloaded / uninspectable callees and `*args` / `**kwargs` @@ -155,12 +292,25 @@ pub(crate) fn trailing_lambda_keyword<'db>(db: &'db dyn Db, callee: Type<'db>) - parameter.name().cloned() } +/// the callable a trailing lambda block fills: the declared type of the callee's +/// last parameter, with what the block's call solved applied to it — so a +/// generic callee's `(T) -> None` is `(str) -> None` for the call it is used in +fn callback_type<'db>(db: &'db dyn Db, callee: BlockCallee<'db>) -> Option> { + let declared = last_parameter(db, callee.ty)?.annotated_type(); + Some(match callee.specialization { + Some(specialization) => declared.apply_specialization(db, specialization), + None => declared, + }) +} + /// the single signature of the callback a trailing lambda block fills: the /// callable the callee's last declared parameter is annotated as. `None` for /// anything else — an unannotated, non-callable or overloaded parameter -fn callback_signature<'db>(db: &'db dyn Db, callee: Type<'db>) -> Option<&'db Signature<'db>> { - let parameter = last_parameter(db, callee)?; - let Type::Callable(callable) = parameter.annotated_type() else { +fn callback_signature<'db>( + db: &'db dyn Db, + callee: BlockCallee<'db>, +) -> Option<&'db Signature<'db>> { + let Type::Callable(callable) = callback_type(db, callee)? else { return None; }; let [signature] = callable.signatures(db).overloads.as_slice() else { @@ -183,7 +333,7 @@ fn declares_receiver(signature: &Signature<'_>) -> bool { /// the block fills that the block does not bind implicitly — the leading one, or /// the one after the receiver when the callback declares one. `None` when that /// shape doesn't hold -fn it_parameter<'db>(db: &'db dyn Db, callee: Type<'db>) -> Option> { +fn it_parameter<'db>(db: &'db dyn Db, callee: BlockCallee<'db>) -> Option> { let signature = callback_signature(db, callee)?; let index = usize::from(declares_receiver(signature)); Some(signature.parameters().get_positional(index)?.clone()) @@ -201,7 +351,10 @@ fn it_parameter<'db>(db: &'db dyn Db, callee: Type<'db>) -> Option(db: &'db dyn Db, callee: Type<'db>) -> Option { +pub(crate) fn trailing_lambda_passes_it<'db>( + db: &'db dyn Db, + callee: BlockCallee<'db>, +) -> Option { let signature = callback_signature(db, callee)?; let parameters = signature.parameters(); // the gradual `(...)` form is the deliberately unchecked one, and a variadic stands @@ -221,7 +374,7 @@ pub(crate) fn trailing_lambda_passes_it<'db>(db: &'db dyn Db, callee: Type<'db>) /// shape is not inspectable — `it` is then left untyped pub(crate) fn trailing_lambda_it_type<'db>( db: &'db dyn Db, - callee: Type<'db>, + callee: BlockCallee<'db>, ) -> Option> { Some(it_parameter(db, callee)?.annotated_type()) } @@ -236,7 +389,7 @@ pub(crate) fn trailing_lambda_it_type<'db>( /// does everywhere else in the borrow analysis. pub(crate) fn trailing_lambda_it_borrow<'db>( db: &'db dyn Db, - callee: Type<'db>, + callee: BlockCallee<'db>, ) -> ParameterBorrow { it_parameter(db, callee).map_or(ParameterBorrow::None, |parameter| parameter.borrow()) } @@ -247,10 +400,9 @@ pub(crate) fn trailing_lambda_it_borrow<'db>( /// member scope pub(crate) fn trailing_lambda_receiver_type<'db>( db: &'db dyn Db, - callee: Type<'db>, + callee: BlockCallee<'db>, ) -> Option> { - let parameter = last_parameter(db, callee)?; - crate::types::receivers::receiver_type(db, parameter.annotated_type()) + crate::types::receivers::receiver_type(db, callback_type(db, callee)?) } /// a callback parameter a trailing lambda block has no way to bind @@ -271,7 +423,7 @@ pub(crate) enum UnbindableParameters { /// (`(...) -> None`, the deliberately unchecked form) pub(crate) fn trailing_lambda_unbindable_parameters<'db>( db: &'db dyn Db, - callee: Type<'db>, + callee: BlockCallee<'db>, ) -> Option { let signature = callback_signature(db, callee)?; let parameters = signature.parameters(); @@ -296,7 +448,7 @@ pub(crate) fn trailing_lambda_unbindable_parameters<'db>( /// single-signature callable (nothing to check against). pub(crate) fn trailing_lambda_callback_return_type<'db>( db: &'db dyn Db, - callee: Type<'db>, + callee: BlockCallee<'db>, ) -> Option> { Some(callback_signature(db, callee)?.return_ty) } diff --git a/crates/ty_server/src/server/api/requests/inlay_hints.rs b/crates/ty_server/src/server/api/requests/inlay_hints.rs index f1df960177..53101670aa 100644 --- a/crates/ty_server/src/server/api/requests/inlay_hints.rs +++ b/crates/ty_server/src/server/api/requests/inlay_hints.rs @@ -153,6 +153,13 @@ fn inlay_hint_kind(inlay_hint_kind: &InlayHintKind) -> lsp_types::InlayHintKind | InlayHintKind::Reification | InlayHintKind::TypeArgument | InlayHintKind::Override + // basedpython-ui: a read set, a dependency set and an invalidation set + // are typing facts about the function, and `unstable` is a modifier + // like `override` + | InlayHintKind::Reads + | InlayHintKind::Stability + | InlayHintKind::DerivedDeps + | InlayHintKind::Invalidates | InlayHintKind::NumericPromotion | InlayHintKind::RevealedType | InlayHintKind::EnumValue => lsp_types::InlayHintKind::Type, diff --git a/crates/ty_server/src/session/options.rs b/crates/ty_server/src/session/options.rs index 12ccb15df2..fa44835af8 100644 --- a/crates/ty_server/src/session/options.rs +++ b/crates/ty_server/src/session/options.rs @@ -410,6 +410,10 @@ pub struct InlayHintOptions { call_type_arguments: Option, type_argument_names: Option, inferred_override: Option, + inferred_reads: Option, + parameter_stability: Option, + derived_dependencies: Option, + inferred_invalidations: Option, numeric_promotions: Option, revealed_types: Option, implicit_parameters: Option, @@ -435,6 +439,10 @@ impl InlayHintOptions { call_type_arguments: self.call_type_arguments.unwrap_or(true), type_argument_names: self.type_argument_names.unwrap_or(true), inferred_override: self.inferred_override.unwrap_or(true), + inferred_reads: self.inferred_reads.unwrap_or(true), + parameter_stability: self.parameter_stability.unwrap_or(true), + derived_dependencies: self.derived_dependencies.unwrap_or(true), + inferred_invalidations: self.inferred_invalidations.unwrap_or(true), numeric_promotions: self.numeric_promotions.unwrap_or(true), revealed_types: self.revealed_types.unwrap_or(true), implicit_parameters: self.implicit_parameters.unwrap_or(true), diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap index 1a9a78548f..b5b2e8c0c1 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__commands__debug_command.snap @@ -25,6 +25,10 @@ Settings: WorkspaceSettings { call_type_arguments: true, type_argument_names: true, inferred_override: true, + inferred_reads: true, + parameter_stability: true, + derived_dependencies: true, + inferred_invalidations: true, numeric_promotions: true, revealed_types: true, implicit_parameters: true, diff --git a/crates/ty_vendored/ty_extensions/_internal.pyi b/crates/ty_vendored/ty_extensions/_internal.pyi index b27f8c8093..6f1bbc81cd 100644 --- a/crates/ty_vendored/ty_extensions/_internal.pyi +++ b/crates/ty_vendored/ty_extensions/_internal.pyi @@ -280,6 +280,15 @@ def is_disjoint_from( def is_singleton(ty: TypeForm[object]) -> bool: """Returns `True` if `ty` is a singleton type with exactly one inhabitant.""" +def is_deeply_immutable(ty: TypeForm[object]) -> bool: + """basedpython: returns `True` if no value of `ty` can change after it is created. + + Scalars, enum members, tuples and frozensets of immutable elements, frozen dataclasses and + named tuples whose fields are immutable, type objects, callables and the `basedpython_ui` + observables are deeply immutable; a `list`, `dict`, `set`, a non-frozen class or `object` + is not. This is the framework's notion of a *stable* value — one that may be held in state. + """ + # ------------------- # Operations on types # ------------------- diff --git a/crates/ty_wasm/src/lib.rs b/crates/ty_wasm/src/lib.rs index 0538f1022a..c801570226 100644 --- a/crates/ty_wasm/src/lib.rs +++ b/crates/ty_wasm/src/lib.rs @@ -1477,6 +1477,10 @@ impl From for InlayHintKind { | ty_ide::InlayHintKind::Reification | ty_ide::InlayHintKind::TypeArgument | ty_ide::InlayHintKind::Override + | ty_ide::InlayHintKind::Reads + | ty_ide::InlayHintKind::Stability + | ty_ide::InlayHintKind::DerivedDeps + | ty_ide::InlayHintKind::Invalidates | ty_ide::InlayHintKind::NumericPromotion | ty_ide::InlayHintKind::RevealedType | ty_ide::InlayHintKind::EnumValue => Self::Type, diff --git a/docs/basedpython/features/editor.md b/docs/basedpython/features/editor.md index 0ab5704c17..9f3aa76002 100644 --- a/docs/basedpython/features/editor.md +++ b/docs/basedpython/features/editor.md @@ -201,28 +201,32 @@ each kind of hint can be turned off on its own through the `ty.inlayHints.` setting your editor passes to the server. all default to on -| setting | shows | -| ---------------------------- | -------------------------------------------------------------- | -| `variableTypes` | the type of a variable the source does not annotate | -| `callArgumentNames` | the parameter each positional argument fills | -| `inferredRaises` | the [exception set](exceptions.md) of an undeclared `def` | -| `inferredVariance` | the [variance](variance.md) inferred for a type parameter | -| `inferredReification` | `reified` on a parameter the body reifies | -| `inferredOverride` | `override` on a method that overrides without saying so | -| `callTypeArguments` | the type arguments inferred for a generic call | -| `typeArgumentNames` | the parameter a positional type argument fills | -| `numericPromotions` | the arms numeric promotion adds to `float` and `complex` | -| `revealedTypes` | what a `reveal_type` call reveals, and what it narrowed | -| `implicitParameters` | a [trailing lambda](trailing-lambdas.md)'s `it` | -| `implicitSelf` | the `self` an [`init(...)`](init-method.md) binds | -| `lambdaParameterTypes` | the type of an unannotated `lambda` parameter | -| `inheritedParameterTypes` | the type a parameter takes from the method it overrides | -| `inheritedParameterDefaults` | the [default](inherited-defaults.md) it takes from that method | -| `inferredReturnTypes` | the return type of a `def` that leaves it out | -| `implicitArguments` | the [context arguments](context-parameters.md) a call fills | -| `enumValues` | the value an [enum](enums.md) member takes implicitly | -| `templateBindingTypes` | a django template `{% for %}` binding's element type | -| `resolvedTemplates` | the file a django `{% extends %}` name resolves to | +| setting | shows | +| ---------------------------- | --------------------------------------------------------------- | +| `variableTypes` | the type of a variable the source does not annotate | +| `callArgumentNames` | the parameter each positional argument fills | +| `inferredRaises` | the [exception set](exceptions.md) of an undeclared `def` | +| `inferredVariance` | the [variance](variance.md) inferred for a type parameter | +| `inferredReification` | `reified` on a parameter the body reifies | +| `inferredOverride` | `override` on a method that overrides without saying so | +| `inferredReads` | the observables a `def` reads while composing (basedpython-ui) | +| `parameterStability` | `unstable` on a composable parameter the runtime cannot compare | +| `derivedDependencies` | what a `derived(...)` computation depends on | +| `inferredInvalidations` | the composables and `derived` values a state write re-runs | +| `callTypeArguments` | the type arguments inferred for a generic call | +| `typeArgumentNames` | the parameter a positional type argument fills | +| `numericPromotions` | the arms numeric promotion adds to `float` and `complex` | +| `revealedTypes` | what a `reveal_type` call reveals, and what it narrowed | +| `implicitParameters` | a [trailing lambda](trailing-lambdas.md)'s `it` | +| `implicitSelf` | the `self` an [`init(...)`](init-method.md) binds | +| `lambdaParameterTypes` | the type of an unannotated `lambda` parameter | +| `inheritedParameterTypes` | the type a parameter takes from the method it overrides | +| `inheritedParameterDefaults` | the [default](inherited-defaults.md) it takes from that method | +| `inferredReturnTypes` | the return type of a `def` that leaves it out | +| `implicitArguments` | the [context arguments](context-parameters.md) a call fills | +| `enumValues` | the value an [enum](enums.md) member takes implicitly | +| `templateBindingTypes` | a django template `{% for %}` binding's element type | +| `resolvedTemplates` | the file a django `{% extends %}` name resolves to | ### keeping a hand-aligned block aligned diff --git a/docs/basedpython/frameworks/basedpython-ui.md b/docs/basedpython/frameworks/basedpython-ui.md new file mode 100644 index 0000000000..45c56e41ab --- /dev/null +++ b/docs/basedpython/frameworks/basedpython-ui.md @@ -0,0 +1,191 @@ +# basedpython-ui support + +basedpython-ui is a compose-style ui library: a `@composable` function describes a piece of ui, and re-runs whenever one of the observables it read changes. the type checker understands that model, so the mistakes it invites are caught where they're written rather than at runtime. + +```by +from basedpython_ui import composable, state, Button, Text + +@composable +def Counter(): + let count = state(0) + Text(f"{count.value}") + Button("+"): + count.value += 1 +``` + +`Counter` reads `count`, so writing `count` re-runs it. everything below follows from that one rule. + +## what runs while composing + +the distinction the checks are all about is *when* code runs: + +- a composable's body, and the `once` content blocks written in it (`Column:`, `Row:`), run **while composing** — every time the ui is described +- a handler block, a lambda, a nested `def` or an effect block runs **later**, in response to an event + +so a read in the body is a dependency of the composition, and a read in a handler is not. a write is the other way round: writing state while composing is an error, and a handler is where writes belong. + +the `root` block of `run_app` / `compose_test` starts a composition of its own, so composables may be called there. + +## what may be held in state + +a `State` notifies its readers when it is *assigned*. a change made *inside* the value it holds notifies nobody, so state may only hold a value that cannot change: + +```by +let items = state([1, 2]) # error: mutable-state-value +let items = state((1, 2)) # ok — a tuple cannot change +let todos = state_list([Todo("a")]) # ok — an observable list of frozen records +``` + +deeply immutable means: the scalars, enum members, a `tuple` or `frozenset` of immutable elements, a `frozen data class` or `NamedTuple` of immutable fields, a type object, a callable, or one of the framework's own observables. + +## the diagnostics + +| lint | what it catches | +| -------------------------------- | --------------------------------------------------------- | +| `mutable-state-value` | a value that can change, put in state | +| `silent-mutation` | an in-place mutation the composition cannot observe | +| `state-write-in-composition` | a state write made while composing | +| `unobservable-dependency` | a composition reading something nothing observes | +| `composable-outside-composition` | a composable or builder called where nothing is composing | +| `conditional-slot` | a `state` / `derived` / effect created under a condition | +| `unstable-parameter` | a composable parameter the runtime cannot compare | +| `content-block-control-flow` | a `return` in a nested content block, which goes nowhere | + +the first five are errors, the last three warnings. all are basedpython-only: under the `ty-compatible` preset they are off. + +### unobservable-dependency + +a composition may only depend on what it can observe. reading a mutable parameter, global or captured local while composing means the ui goes stale as soon as anything changes it — from another module, a `.py` caller, a callback: + +```by +@composable +def Names(items: list[str]): + Text(str(len(items))) # error: unobservable-dependency +``` + +hold it in state (`state_list`, `state_dict`), pass an immutable value (a `tuple`, a `frozen data class`), or read it only in a handler. a name the composition binds itself is this run's own value and is never reported, whatever its type. + +### unstable-parameter + +a composable is skipped on recomposition only when every argument is *stable* and compares equal to the last one. a `list`, `dict`, `set` or non-frozen class cannot be compared, so the composable re-runs on every recomposition of its parent: + +```by +@composable +def TodoList(items: list[int]): ... # warning: unstable-parameter + +@composable +def Skippable(items: tuple[int, ...]): ... # ok +``` + +this is about skipping alone. a read-only view (`list[out int]`) *is* stable — the runtime compares it structurally — but reading one while composing is still an `unobservable-dependency`, because the view restricts this reader and not the other holders of the list. it is not the spelling to reach for. + +### conditional-slot + +a slot lives as long as its enclosing composition scope and is identified by its call site, so one created under a condition is disposed — its state lost, its effect cancelled — as soon as the condition stops holding: + +```by +@composable +def Profile(show: bool): + if show: + let clicks = state(0) # warning: conditional-slot +``` + +state that should outlive a condition belongs above it. a `finally` body is not a condition: it runs however the `try` exited. + +## writing a component library + +two decorators mark what the checker treats specially, and both are resolved by their definition, so an alias or a re-export works: + +- `@composable` — the function's body is a composition scope +- `@builder` — the function emits into the composition being built, so it can only be called while composing + +both are defined in `basedpython_ui.runtime` and re-exported by `basedpython_ui`: + +```by +from basedpython_ui import builder, Text + +@builder +def Badge(text: str): + Text(f"[{text}]") +``` + +a builder is not a scope: it emits into the composable that called it and re-runs with it, so it never appears as a recomposition cause — not in the runtime's trace, and not in an `invalidates` hint. what it reads while composing, its caller reads + +a helper that emits nothing needs neither, and stays callable from anywhere — including a helper that lives beside the builders in `basedpython_ui.widgets`. + +## a `context` parameter with a content block + +a [`context` parameter](../features/context-parameters.md) is filled by keyword, so nothing may follow it that a positional argument could land on. the callback a [trailing block](../features/trailing-lambdas.md) fills is the exception, because the call passes it by keyword — but only when it carries the `once` or `local` modifier that marks it a borrowed callback: + +```by +def Card(title: str, context theme: str, once content: () -> None): + content() + +Card("x"): + pass +``` + +a plain callable parameter after a `context` parameter is rejected: `Card("x", handler)` would bind `handler` to `theme`. write it keyword-only (after a bare `*`) if it must follow. + +## in the editor + +four inlay hints show what the checker knows, each toggleable through `ty.inlayHints`: + +- `inferredReads` — the observables a composable reads, on its header +- `derivedDependencies` — what a `derived(...)` computation depends on +- `parameterStability` — `unstable` before a parameter the runtime cannot compare +- `inferredInvalidations` — what a state write made after composing invalidates, at the end of the statement — or the lambda — that writes + +```by +@composable +def Counter(step: int = 1)⟨ reads count, doubled⟩: + let count = state(0) + let unread = state(0) + let doubled = derived(lambda: count.value * 2)⟨ depends on count⟩ + Text(f"{count.value} {doubled.value}") + Button("+"): + count.value += step⟨ invalidates Counter, doubled⟩ + Button("reset", on_click=lambda: count.set(0)⟨ invalidates Counter, doubled⟩) + Button("skip"): + unread.set(1)⟨ invalidates nothing⟩ +``` + +the read set is a superset: invalidation at runtime always uses the exact set, so an imprecise hint can never mean a missed re-render. a callee that cannot be followed shows as `…`, and so does an unpacked argument (`Child(*cells)`) a callee reads through + +### what `invalidates` names + +the invalidation set is static, and a superset in the same way. it names every scope the runtime re-runs for the write, in the order they are declared, the write's own file first: + +- the composables whose own composition reads the place — in the body, in the `once` / `local` content blocks written in it, and in the plain functions it calls while composing +- a composable handed the slot as an argument, when it reads its parameter — handed directly, through a plain helper's parameter, through a helper that captures the slot, or across modules — rather than the parent that only forwards it: the runtime skips a forwarding parent whose arguments did not change +- a composable called *with a content block* (`Card(count):`), together with its parent. the runtime runs such a child inline: it re-runs whenever its parent does and is never skipped, and what it reads — its own cells included — subscribes the parent's scope, through as many inline parents as there are. so a write to a slot an inline child reads names the child and the parent, and a write that re-runs a parent names every inline child under it +- the `derived` computations whose lambda reads the place and, through them, whatever reads those; a `remember` counts for the scope that made it +- the `root` of `run_app`, `compose_test` and `Runtime.set_root` + +a *slot* is a name bound while composing to what a call returned — `let count = state(0)` — in the body or in a content block written in it: a slot declared under `Column:` is the composable's. a name bound to another place — `let alias = count`, `let cell = model.count` — is followed to that place, binding by binding, whether the alias is made in the body or in the handler that writes it + +a write nobody observes says `nothing`, and that is said only of a slot no composition reads. what cannot be followed ends the set with `…`, after whatever the walk did see: + +- a module-level slot, which another file may read +- a parameter, which a caller in another file may fill +- a slot of a composable with a callable last parameter, which a caller in another file may call with a content block and so subscribe to it — unless the composable is `private`, which no other file can call +- a callee reached through a `dynamic` value, and an unpacked argument (`Child(*cells)`), which may hand the slot to anything +- a written name that is not a slot: a loop or comprehension target, a value bound in a handler or outside every composition, a subscript — what it holds may have readers anywhere + +`reads` and `invalidates` disagree on a forwarding parent, on purpose. `def Forwarding(count)⟨ reads count⟩` lifts every composable callee's reads into its caller — a superset that says what the subtree depends on — while a write to that slot says `invalidates Child` and not `Forwarding`, because the runtime subscribes the child's own scope and skips the parent, whose arguments did not change. only a child called with a content block subscribes its parent, and then both hints name the parent + +the runtime's exact answer is its trace: why every scope ran, which `bpd` reads and pycharm shows — see [why did this rerender](https://kotlinisland.github.io/basedpython-ui/guide/why-did-this-rerender/) + +## limitations + +### the read set is approximate + +reads are recovered statically by following calls. a callee reached through a `dynamic` value cannot be followed, nor can what an unpacked argument hands a callee, and the hint says so with `…` rather than claiming the set is complete. the invalidation set is built from the same walk, one file at a time, and marks what it cannot see the same way — see [what `invalidates` names](#what-invalidates-names). + +### `silent-mutation` sees only what is written here + +it reports the mutations in the file it is checking. a mutation made in another module, or by a `.py` caller, is invisible — which is why `unobservable-dependency` exists: keeping a composition from depending on a mutable value in the first place is the guarantee that holds generally. + +### recognized on any search path + +unlike the other frameworks here, `basedpython_ui` is recognized wherever it resolves — a first-party package as well as an installed one — because it is developed in place. a first-party package named `basedpython_ui` is treated as the framework. diff --git a/docs/basedpython/frameworks/index.md b/docs/basedpython/frameworks/index.md index 0f6bbc294a..3c493eebd9 100644 --- a/docs/basedpython/frameworks/index.md +++ b/docs/basedpython/frameworks/index.md @@ -24,6 +24,13 @@ keeps basedpython features working inside them fixture injection typed end to end, plus diagnostics for fixtures that don't exist +- :material-view-dashboard-outline:{ .lg .middle } **[basedpython-ui](basedpython-ui.md)** + + ______________________________________________________________________ + + composition scopes, observable state, and the checks that keep a ui from + going stale + - :simple-django:{ .lg .middle } **[Django](django.md)** ______________________________________________________________________ diff --git a/hawk.toml b/hawk.toml index a85e7fae6e..c5c897b075 100644 --- a/hawk.toml +++ b/hawk.toml @@ -871,14 +871,6 @@ kind = "inherent_method" level = "expect" reason = "command descriptions expose a complete argument builder and inspection API" -[[override]] -lint = "hawk::dead_public" -crate = "ty_python_core" -item = "expression::Expression::<'db>::program" -kind = "inherent_method" -level = "expect" -reason = "semantic ingredients expose consistent file, scope, and program accessors" - [[override]] lint = "hawk::dead_public" crate = "ty_python_core" diff --git a/ty.schema.json b/ty.schema.json index ed5cd6f832..aeed621c23 100644 --- a/ty.schema.json +++ b/ty.schema.json @@ -780,6 +780,26 @@ } ] }, + "composable-outside-composition": { + "title": "detects a composable or builder called outside a composition", + "description": "## What it does\n\nChecks for a call to a `basedpython_ui` composable (a function decorated `@composable`) or to one\nof the framework's widget builders (`Text`, `Button`, `Column`, …) from somewhere that is not a\ncomposition: a function that is not itself a composable, a handler block, a lambda or a nested\n`def`. A composable's body, the `once` content blocks and `local` blocks written in it, and the\n`root` block of `run_app` / `compose_test` are compositions.\n\n## Why is this bad?\n\nA composable opens a scope in the composition being built and a builder emits into it; neither has\nanything to build into outside of one. The runtime raises `CompositionError` at the call; this\ncheck reports it at the source.\n\n## Examples\n\n```by\nfrom basedpython_ui import composable, run_app, Button, Text\n\n@composable\ndef Counter(): ...\n\ndef helper():\n Counter() # error: `helper` is not a composable\n\n@composable\ndef App():\n Button(\"x\"):\n Text(\"clicked\") # error: a handler runs after composition\n\ndef main():\n run_app(\"app\"):\n App() # ok: the root of the composition\n```", + "default": "error", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, + "conditional-slot": { + "title": "detects a basedpython-ui slot created under a condition", + "description": "## What it does\n\nChecks for a `basedpython_ui` slot — `state`, `state_list`, `state_dict`, `derived`, `remember`,\n`launched_effect`, `disposable_effect`, `side_effect` — created under a condition in a composable:\ninside an `if`, `for`, `while`, `try` or `match`, inside a comprehension, or inside a block that is\nnot a `once` content block (a handler block, a lambda, a nested `def`).\n\n## Why is this bad?\n\nA slot lives as long as its enclosing composition scope and is identified by its call site, so a\nconditional slot is created when the condition first holds and disposed — its state lost, its\neffect cancelled — as soon as it stops holding. That is rarely what the code means: state that\nshould outlive a condition belongs above it, and a slot created from a handler has no scope to live\nin at all. The runtime keys slots by call site, so this is safe at runtime; the check makes the\nlifetime visible.\n\n## Examples\n\n```by\nfrom basedpython_ui import composable, state, Text\n\n@composable\ndef Profile(show: bool):\n if show:\n let clicks = state(0) # warning: created and disposed as `show` changes\n Text(f\"{clicks.value}\")\n\n@composable\ndef Fixed(show: bool):\n let clicks = state(0) # ok: lives as long as `Fixed`\n if show:\n Text(f\"{clicks.value}\")\n```", + "default": "warn", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "conflicting-declarations": { "title": "detects conflicting declarations", "description": "## What it does\n\nChecks whether a variable has been declared as two conflicting types.\n\n## Why is this bad\n\nA variable with two conflicting declarations likely indicates a mistake. Moreover, it could lead to\nincorrect or ill-defined type inference for other code that relies on these variables.\n\n## Examples\n\n```python\nif __name__ == \"__main__\":\n a: int\nelse:\n a: str\n\na = 1 # error\n```", @@ -800,6 +820,16 @@ } ] }, + "content-block-control-flow": { + "title": "detects a `return` inside a nested `once` content block", + "description": "## What it does\n\nChecks for a `return` inside a `once` content block that is itself written inside another\ntrailing-lambda block.\n\n## Why is this bad?\n\nA `once` block runs exactly once, inline, so a `return` inside it is allowed to leave the enclosing\nscope — but only one level: the language propagates a block's `return` to the scope the block is\nwritten in. When that scope is itself a block, the `return` leaves the inner block and stops there;\nthe enclosing function keeps running, and the returned value is silently discarded.\n\n(A `break` or `continue` inside any block is already rejected as `break` outside loop: a block is\nits own function.)\n\n## Examples\n\n```by\ndef Column(once content: () -> None):\n content()\n\ndef Row(once content: () -> None):\n content()\n\ndef App(done: bool) -> int:\n Column:\n Row:\n if done:\n return 1 # error: [content-block-control-flow]\n return 2 # ok: one level, leaves `App`\n return 0\n```", + "default": "error", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "cyclic-class-definition": { "title": "detects cyclic class definitions", "description": "## What it does\n\nChecks for class definitions in stub files that inherit (directly or indirectly) from themselves.\n\n## Why is it bad?\n\nAlthough forward references are natively supported in stub files, inheritance cycles are still\ndisallowed, as it is impossible to resolve a consistent [method resolution order] for a class that\ninherits from itself.\n\n## Examples\n\n`foo.pyi`:\n\n```pyi\nclass A(B): ... # error\nclass B(A): ... # error\n```\n\n[method resolution order]: https://docs.python.org/3/glossary.html#term-method-resolution-order", @@ -1860,6 +1890,16 @@ } ] }, + "mutable-state-value": { + "title": "detects a mutable value held in basedpython-ui state", + "description": "## What it does\n\nChecks for a value that is not deeply immutable being placed in `basedpython_ui` state: the\ninitial value of `state(...)` / `State(...)`, the elements of `state_list(...)` / `StateList(...)`,\nthe value computed by `derived(...)` / `remember(...)`, a value assigned to a `State`'s `.value`,\nappended to or inserted into a `StateList`, stored into a `StateDict`, or given to `provide(...)`.\n\n## Why is this bad?\n\nA `State` notifies its readers when it is *assigned*. A change made *inside* the held value —\n`items.append(1)` on a held `list`, a field written on a held plain class — notifies nobody, so the\nui keeps showing the old value until something unrelated recomposes it. The runtime refuses such a\nvalue with a `TypeError`; this check reports it at the source.\n\nA value is deeply immutable when nothing reachable from it can change: the scalars, enum members, a\n`tuple` or `frozenset` of immutable elements, a `frozen data class` or `NamedTuple` of immutable\nfields, a type object, a callable, or one of the framework's own observables (`State`, `StateList`,\n`StateDict`, `Derived`, `Ambient`).\n\n## Examples\n\n```by\nfrom basedpython_ui import composable, state, state_list\n\nfrozen data class Todo:\n title: str\n\n@composable\ndef App():\n let items = state([1, 2]) # error: `list[int]` cannot be held in state\n let names = state((1, 2)) # ok: a tuple of immutables\n let todos = state_list([Todo(\"a\")]) # ok: an observable list of frozen records\n```", + "default": "error", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "narrowing-guard-as-value": { "title": "detects an assertion guard whose result is used as a value", "description": "## What it does\nChecks for a call to a basedpython assertion guard whose result is used as a value.\n\n## Why is this bad?\nAn assertion guard narrows once it *returns*, so it is written as a statement:\n`check(x)`. Its value is `None` — it raises when the assertion doesn't hold — so\ntesting that value (`if check(x):`) or binding it (`ok = check(x)`) never gets the\nnarrowing, and the test is always false.\n\n## Example\n\n```by\ndef check(x: int | None) -> asserts x:\n if x is None:\n raise ValueError\n\ndef f(a: int | None):\n if check(a): # error: the guard narrows as a statement, not as a test\n ...\n```", @@ -2230,6 +2270,26 @@ } ] }, + "silent-mutation": { + "title": "detects an in-place mutation a basedpython-ui composition cannot observe", + "description": "## What it does\n\nChecks for an in-place mutation written inside a `basedpython_ui` composable — in its body, in a\n`once` content block written in it, or in a handler block, lambda or nested `def` written in it: a\nmutating method call (`append`, `extend`, `insert`, `pop`, `remove`, `clear`, `sort`, `reverse`,\n`update`, `setdefault`, `popitem`, `add`, `discard`, …) on a builtin mutable container, an in-place\noperator (`+=`, `|=`, …) on one, a subscript store or delete on one, or an attribute store on an\ninstance of a class that is not frozen and not an observable.\n\nA container the same body creates itself — bound to a display, a comprehension or a constructor\ncall — is a fresh local, and mutating it is allowed.\n\n## Why is this bad?\n\nA composition re-runs when an observable it read is written. A `list` or a plain object is not\nobservable: mutating it in place changes what the ui *should* show without telling the runtime,\nso the change is not seen until something unrelated recomposes the scope. Mutate a `StateList` /\n`StateDict`, or rebuild an immutable value and assign it to a `State`, and the change notifies.\n\n## Examples\n\n```by\nfrom basedpython_ui import composable, state_list, Button\n\n@composable\ndef TodoList(items: list[str]):\n Button(\"add\"):\n items.append(\"x\") # error: mutates `list[str]` in place\n\n@composable\ndef Observed():\n let items = state_list([\"a\"])\n Button(\"add\"):\n items.append(\"x\") # ok: a `StateList` write notifies its readers\n```", + "default": "error", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, + "state-write-in-composition": { + "title": "detects a basedpython-ui state write made while composing", + "description": "## What it does\n\nChecks for a write to `basedpython_ui` state made while a composition is running: in a composable's\nbody or in a `once` content block written in it, an assignment to a `State`'s `.value` (plain or\naugmented), a call to `State.set` / `State.update`, or a mutating call, subscript store or delete\non a `StateList` / `StateDict`.\n\nWrites made from a handler block, a lambda, a nested `def` or an effect block are not in\ncomposition: those run later, in response to an event, and are the right place for them.\n\n## Why is this bad?\n\nComposition is a pure description of the ui for the current state. A write made while composing\ninvalidates the very scope being composed (or one already composed this frame), which would loop\nor show a frame that is half old and half new. The runtime raises `CompositionError` before\napplying such a write; this check reports it at the source.\n\n## Examples\n\n```by\nfrom basedpython_ui import composable, state, Button, Text\n\n@composable\ndef Counter():\n let count = state(0)\n count.value = 1 # error: written while `Counter` is composing\n Text(f\"{count.value}\")\n Button(\"+\"):\n count.value += 1 # ok: a handler runs after composition\n```", + "default": "error", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "static-assert-error": { "title": "Failed static assertion", "description": "## What it does\n\nMakes sure that the argument of `static_assert` is statically known to be true.\n\n## Why is this bad?\n\nA `static_assert` call represents an explicit request from the user for the type checker to emit an\nerror if the argument cannot be verified to evaluate to `True` in a boolean context.\n\n## Examples\n\n```python\nfrom ty_extensions import static_assert\n\n# evaluates to `False`\nstatic_assert(1 + 1 == 3) # error\n\n# does not have a statically known truthiness\nstatic_assert(int(2.0 * 3.0) == 6) # error\n```", @@ -2520,6 +2580,16 @@ } ] }, + "unobservable-dependency": { + "title": "detects a basedpython-ui composition reading a value it cannot observe", + "description": "## What it does\n\nChecks for a read, made while a `basedpython_ui` composition runs, of a value it cannot observe:\na load of a parameter of the composable (a `context` parameter included), of a module global, or\nof a local captured from an enclosing function, whose type is neither deeply immutable nor one of\nthe framework's observables (`State`, `StateList`, `StateDict`, `Derived`, `Ambient`).\n\nWhat runs while composing is the composable's body, the `once` content blocks and `local` blocks\nwritten in it, and the lambda given to `derived(...)` / `remember(...)`. A handler block, a lambda,\na nested `def` or an effect block runs later, so a read there is not a dependency of the\ncomposition. A name the composition binds itself — a local of the body or of a block, a `for`\ntarget, a comprehension variable — is this run's own value and is not reported, whatever its\ntype; a `dynamic` value is exempt, as everywhere. A read-only view (`list[out str]`) is reported\nlike a plain `list`: it restricts this reader, not the other holders of the list.\n\n## Why is this bad?\n\nA mutation of non-observable data is never a trigger: an immutable value cannot change, an\nobservable notifies its readers when it does, and a mutable value changes without telling anyone.\nA composition that reads a mutable parameter or global therefore shows a stale ui after any change\nto it — wherever that change is made: another module, a `.py` caller, a `dynamic` value, a\ncallback. `silent-mutation` reports the writes it can see; this check is what makes the guarantee\ngeneral, by keeping a composition from depending on such a value in the first place.\n\nHold the value in state (`state_list`, `state_dict`), pass an immutable value (a `tuple`, a\n`frozen data class`), or read it only in a handler.\n\n## Examples\n\n```by\nfrom basedpython_ui import composable, state_list, Text\n\n@composable\ndef Names(items: list[str]):\n Text(str(len(items))) # error: read while `Names` composes, but nothing observes it\n\n@composable\ndef Frozen(items: tuple[str, ...]):\n Text(str(len(items))) # ok: a tuple cannot change\n\n@composable\ndef Held():\n let items = state_list([\"a\"])\n Text(str(len(items))) # ok: a `StateList` notifies its readers\n```", + "default": "error", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "unresolved-attribute": { "title": "detects references to unresolved attributes", "description": "## What it does\n\nChecks for unresolved attributes.\n\n## Why is this bad?\n\nAccessing an unbound attribute will raise an `AttributeError` at runtime. An unresolved attribute is\nnot guaranteed to exist from the type alone, so this could also indicate that the object is not of\nthe type that the user expects.\n\n## Examples\n\n```python\nclass A: ...\n\n\n# AttributeError: 'A' object has no attribute 'foo'\nA().foo # error\n```", @@ -2650,6 +2720,16 @@ } ] }, + "unstable-parameter": { + "title": "detects a composable parameter whose type is not stable", + "description": "## What it does\n\nChecks for a parameter of a `basedpython_ui` composable whose declared type is not *stable*:\nnot deeply immutable, and not a read-only view of immutable elements (`list[out int]`).\n\n## Why is this bad?\n\nA composable is skipped on recomposition only when every argument is stable and equal to the last\none. A `list`, `dict`, `set` or non-frozen class can be changed behind the composable's back, so the\nruntime cannot compare it and never skips the scope: the composable re-runs on every recomposition\nof its parent, however little changed. Prefer an immutable spelling (`tuple[int, ...]`, a\n`frozen data class`) or an observable (`state_list`, `state_dict`).\n\nThis is a warning about skipping alone: a mutable parameter that only a handler touches never\ntriggers a re-render, but does not make the composition stale on its own. Reading one while\ncomposing is what does that, and is reported as an `unobservable-dependency`. A read-only view\n(`list[out int]`) is stable for skipping — the runtime compares it structurally at recomposition —\nbut is still unobservable when read, so it is not the spelling to reach for.\n\n## Examples\n\n```by\nfrom basedpython_ui import composable, StateList\n\n@composable\ndef TodoList(items: list[int]): ... # warning: never skipped\n\n@composable\ndef Skippable(items: tuple[int, ...]): ... # ok\n\n@composable\ndef Observed(items: StateList[int]): ... # ok: an observable handle\n```", + "default": "warn", + "oneOf": [ + { + "$ref": "#/definitions/Level" + } + ] + }, "unsupported-base": { "title": "detects class bases that are unsupported as ty could not feasibly calculate the class's MRO", "description": "## What it does\n\nChecks for class definitions that have bases which are unsupported by ty.\n\n## Why is this bad?\n\nIf a class has a base that is an instance of a complex type such as a union type, ty will not be\nable to resolve the [method resolution order] (MRO) for the class. This will lead to an inferior\nunderstanding of your codebase and unpredictable type-checking behavior.\n\n## Examples\n\n```python\nimport datetime\n\n\nclass A: ...\n\n\nclass B: ...\n\n\nif datetime.date.today().weekday() != 6:\n C = A\nelse:\n C = B\n\n\nclass D(C): ... # error: [unsupported-base]\n```\n\n[method resolution order]: https://docs.python.org/3/glossary.html#term-method-resolution-order", diff --git a/zensical.toml b/zensical.toml index 34b09cfed4..51f1db1b39 100644 --- a/zensical.toml +++ b/zensical.toml @@ -218,6 +218,7 @@ features = [ { "pytest" = "frameworks/pytest.md" }, { "Django" = "frameworks/django.md" }, { "Django templates" = "frameworks/django-templates.md" }, + { "basedpython-ui" = "frameworks/basedpython-ui.md" }, ] [[project.nav]] From bde8a6757152172517d255d6317c6d11e5af3dc6 Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:06:01 +1000 Subject: [PATCH 10/11] map each line of a hoisted block to the line that produced it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a trailing-lambda block is hoisted into a `def` placed before the statement that owns it, and the source map charged every line of that `def` — its header, its whole body, and every block nested inside it — to the one line the edit started on, while the owning statement's own generated line got whatever line the re-emitted source ended at. so a traceback inside a handler named the `Column(…):` that owns the handler's block, a breakpoint set on the write bound to that line too, and the ui framework's trace reported a state write against a statement eleven lines above the write. the bug was in the table primitive rather than the lowering. an edit's replacement is not one thing: a template re-emits spans of the source it rewrites around text the lowering wrote itself, and reading it back as a `String` loses which is which. a `Replacement` now remembers the runs it was assembled from — `Copied(source offset)` and `Generated(anchor offset)` — and the table charges an output line to the first copied text on it, failing that to the anchor of the first generated text on it (a hoisted header, an injected keyword: the construct they stand for), and failing that to the run that terminates the line, so a copied blank line maps to itself. whitespace charges nothing, which is what puts an injected keyword on the closing-paren line of a multi-line header with the statement it belongs to. a statement re-rendered from its AST keeps no ranges, so every line of it is still charged to the statement; that is stated where it happens and pinned by a test rather than left to be rediscovered. Co-Authored-By: Claude Opus 5 --- crates/by_transforms/src/source_map.rs | 823 +++++++++++++++++- .../src/transforms/ast_driver.rs | 58 +- crates/ty/tests/by_e2e.rs | 59 ++ 3 files changed, 868 insertions(+), 72 deletions(-) diff --git a/crates/by_transforms/src/source_map.rs b/crates/by_transforms/src/source_map.rs index e7bec45782..0b207ad894 100644 --- a/crates/by_transforms/src/source_map.rs +++ b/crates/by_transforms/src/source_map.rs @@ -1,10 +1,192 @@ +//! The output-line → input-line table for one edit-application pass, and the +//! replacement type that lets it say where each line of a replacement came from. +//! +//! An edit's replacement is not one thing: a template re-emits spans of the +//! source it rewrites — a trailing-lambda block's whole suite, the call it hangs +//! off — around text the lowering wrote itself. Reading the replacement as a +//! string loses that, and every line of it then has to be charged to the one +//! line the edit started on: a traceback inside a hoisted block would name the +//! statement that owns the block instead of the line that raised. So a +//! [`Replacement`] keeps the runs it was assembled from, and the table charges a +//! copied run to the line it was copied from and generated text to the construct +//! it stands for. + +/// Where one run of a replacement's text came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Origin { + /// copied verbatim from the source, starting at this byte offset + Copied(usize), + /// written by a lowering; it stands for the construct at this source offset + /// (the start of the edit that wrote it), which is the one `.by` position a + /// reader can be pointed at for text no source spells + Generated(usize), +} + +/// The replacement text of one edit, remembered as the runs it was assembled +/// from: spans copied out of the source, and text a lowering generated. +/// +/// The runs are what a line table is built from. They do not change what is +/// written: [`text`](Self::text) is the replacement, exactly as it would have +/// been assembled into a plain string. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct Replacement { + text: String, + /// `(offset into `text`, origin)` of each run, ascending; a run extends to + /// the next run's start, or to the end of the text. never an empty run, and + /// contiguous copies (and generated runs with one anchor) are merged, so + /// two replacements assembled from different fragments compare equal when + /// they say the same thing + runs: Vec<(usize, Origin)>, +} + +impl Replacement { + /// Text no source span spells, standing for the construct at `anchor` — a + /// re-rendered statement, a plain-text substitution. + pub(crate) fn generated(text: &str, anchor: usize) -> Self { + let mut replacement = Self::default(); + replacement.push_generated(text, anchor); + replacement + } + + /// Append text a lowering wrote, standing for the construct at `anchor`. + pub(crate) fn push_generated(&mut self, text: &str, anchor: usize) { + if text.is_empty() { + return; + } + let origin = Origin::Generated(anchor); + if self.runs.last().is_none_or(|&(_, last)| last != origin) { + self.runs.push((self.text.len(), origin)); + } + self.text.push_str(text); + } + + /// Append `source[start..end]` verbatim. + pub(crate) fn push_source(&mut self, source: &str, start: usize, end: usize) { + if start >= end { + return; + } + let continues = self.runs.last().is_some_and(|&(at, origin)| { + matches!(origin, Origin::Copied(from) if from + (self.text.len() - at) == start) + }); + if !continues { + self.runs.push((self.text.len(), Origin::Copied(start))); + } + self.text.push_str(&source[start..end]); + } + + /// The replacement text. + pub(crate) fn text(&self) -> &str { + &self.text + } + + /// The runs, as `(text, origin)`, in order. + fn runs(&self) -> impl Iterator { + self.runs.iter().enumerate().map(|(i, &(at, origin))| { + let end = self + .runs + .get(i + 1) + .map_or(self.text.len(), |&(next, _)| next); + (&self.text[at..end], origin) + }) + } +} + +/// The byte offsets `source`'s lines start at, for turning an offset into a +/// line. +/// +/// Lines are delimited by `\n` alone, as everything downstream counts them: the +/// table is indexed by python's line numbers, and a table that broke lines +/// differently from the text it describes would be off by one from that point +/// on. +struct LineStarts(Vec); + +impl LineStarts { + fn of(source: &str) -> Self { + Self( + std::iter::once(0) + .chain(source.match_indices('\n').map(|(i, _)| i + 1)) + .collect(), + ) + } + + /// The 0-based line `offset` is on. An offset at or past the end of the + /// source is on the last line. + fn line_of(&self, offset: usize) -> u32 { + let index = self.0.partition_point(|&start| start <= offset); + u32::try_from(index.saturating_sub(1)).unwrap_or(u32::MAX) + } +} + +/// The table under construction: one entry per completed output line, plus +/// what is known about the line being built. +/// +/// A line is charged to the first visible *source* text on it, and only failing +/// that to the construct the first visible generated text on it stands for. +/// Generated text is mostly glue around copied operands — the indentation ahead +/// of a re-emitted call, `type(` around a receiver, `cell.append(` around a +/// returned value — and the operand is the thing the reader wants named. A line +/// whose visible text is all generated (a hoisted `def` header, a `nonlocal`, an +/// injected keyword argument on a line of its own) is charged to the edit that +/// wrote it. Whitespace charges nothing: the indentation copied from the line a +/// call's closing paren sat on says nothing about the keyword written after it. +/// +/// Charged when the line is *opened* rather than when it is closed: the `\n` +/// that closes a line may be copied from a source line other than the one the +/// line's text came from — the newline after a comment that trails a moved +/// block, say — and it says nothing about the text before it. A line holding no +/// visible text at all is the line of whatever run terminates it, so a blank +/// line copied from the source maps to itself. +#[derive(Default)] +struct Table { + lines: Vec>, + /// the source line of the first visible copied text on the open line + copied: Option, + /// the anchor line of the first visible generated text on the open line + generated: Option, +} + +impl Table { + /// Append `text`, charged to `line` — the line it was copied from (advancing + /// as the text does) when `copied`, and otherwise the line it stands for. + fn push(&mut self, text: &str, mut line: u32, copied: bool) { + for byte in text.bytes() { + if byte == b'\n' { + let origin = self.copied.or(self.generated).unwrap_or(line); + self.lines.push(Some(origin)); + self.copied = None; + self.generated = None; + if copied { + line += 1; + } + } else if byte.is_ascii_whitespace() { + continue; + } else if copied { + self.copied.get_or_insert(line); + } else { + self.generated.get_or_insert(line); + } + } + } + + /// The table, with an entry for a last line nothing terminated. + fn finish(mut self) -> Vec> { + if let Some(origin) = self.copied.or(self.generated) { + self.lines.push(Some(origin)); + } + self.lines + } +} + /// Build an output-line → input-line table for a single edit-application pass. /// /// `edits` must be ascending by start and non-overlapping, expressed in `source` /// byte coordinates (the same shape `replace_range` is fed). Each output *line* -/// maps to the input line it came from. This is the line-level primitive the -/// run-time traceback rewriter composes; column-accurate mapping is future work -/// (see `docs/basedpython/development/sourcemaps.md`). +/// maps to the input line it came from: a line copied from the source to that +/// line, a line of a replacement to the line of the source text it re-emits — +/// or, where it re-emits none, to the line of the construct the edit rewrote +/// (see `Table`). This is the line-level primitive the run-time traceback +/// rewriter composes; column-accurate mapping is future work (see +/// `docs/basedpython/development/sourcemaps.md`). /// /// A line, and not a `\n`. The two only differ at the end of a file that has no /// terminator on its last line, and there the difference is the whole thing: that @@ -20,58 +202,29 @@ /// /// (Named rather than linked: this is public and that is not, and rustdoc rejects a /// link from one to the other.) -pub(crate) fn line_table(source: &str, edits: &[(usize, usize, String)]) -> Vec> { - let mut lines: Vec> = Vec::new(); +pub(crate) fn line_table(source: &str, edits: &[(usize, usize, Replacement)]) -> Vec> { + let starts = LineStarts::of(source); + let mut table = Table::default(); let mut src_pos = 0usize; - let mut input_line = 0u32; - // the input line behind the output line being built: set once a character - // has landed on it, cleared by the `\n` that completes it. what it holds at - // the end is the entry for a last line nothing terminated - let mut open: Option = None; - - for (start, end, new_text) in edits { - for ch in source[src_pos..*start].chars() { - if ch == '\n' { - lines.push(Some(input_line)); - input_line += 1; - open = None; - } else { - open = Some(input_line); + for (start, end, replacement) in edits { + table.push(&source[src_pos..*start], starts.line_of(src_pos), true); + for (text, origin) in replacement.runs() { + match origin { + Origin::Copied(from) => table.push(text, starts.line_of(from), true), + Origin::Generated(anchor) => table.push(text, starts.line_of(anchor), false), } } - let consumed = source[*start..*end].chars().filter(|&c| c == '\n').count(); - // a replacement's lines are all attributed to the line the edit starts - // on: it is the one `.by` line the reader can be pointed at, whatever - // shape the generated text took - for ch in new_text.chars() { - if ch == '\n' { - lines.push(Some(input_line)); - open = None; - } else { - open = Some(input_line); - } - } - input_line += u32::try_from(consumed).unwrap_or(0); src_pos = *end; } - for ch in source[src_pos..].chars() { - if ch == '\n' { - lines.push(Some(input_line)); - input_line += 1; - open = None; - } else { - open = Some(input_line); - } - } - if let Some(origin) = open { - lines.push(Some(origin)); - } - lines + table.push(&source[src_pos..], starts.line_of(src_pos), true); + table.finish() } #[cfg(test)] mod tests { - use super::line_table; + use indoc::indoc; + + use super::{Replacement, line_table}; /// The count that everything downstream indexes by. Stated as a property /// over both end-of-file shapes, because the whole bug this guards was a @@ -139,12 +292,586 @@ mod tests { #[test] fn an_edit_on_the_last_line_keeps_it_mapped() { // `b = 2` → `b = 22`, no newline anywhere near it - let widened = line_table("a = 1\nb = 2", &[(10, 11, "22".to_owned())]); + let widened = line_table( + "a = 1\nb = 2", + &[(10, 11, Replacement::generated("22", 10))], + ); assert_eq!(widened, vec![Some(0), Some(1)]); // a replacement that brings its own line break: both generated lines // are attributed to the `.by` line the edit started on - let split = line_table("a = 1\nb = 2", &[(6, 11, "b = 2\nc = 3".to_owned())]); + let split = line_table( + "a = 1\nb = 2", + &[(6, 11, Replacement::generated("b = 2\nc = 3", 6))], + ); assert_eq!(split, vec![Some(0), Some(1), Some(1)]); } + + /// generated text has no line of its own, so every line of it is charged + /// to the construct it was written for — and the text after the edit goes + /// on mapping to its own lines, wherever the replacement left off + #[test] + fn a_generated_replacement_is_charged_to_the_construct_it_rewrote() { + let source = "a = 1\nb = 2\nc = 3\n"; + let table = line_table( + source, + &[(6, 11, Replacement::generated("x = 0\ny = 0\nz = 0", 6))], + ); + assert_eq!(table, vec![Some(0), Some(1), Some(1), Some(1), Some(2)]); + } + + /// the shape a trailing-lambda block lowers to: the suite is hoisted into a + /// `def` ahead of the call it hung off, so the replacement re-emits source + /// out of order. each copied line maps to the line it was copied from, the + /// generated `def` header to the block statement, and the re-emitted call + /// to its own line — not to the line of the suite's last statement, which + /// is where the text after the edit resumes + #[test] + fn copied_runs_map_to_the_lines_they_were_copied_from() { + let source = "f(1):\n print(it)\n raise E # note\nprint(2)\n"; + let call_end = 3; // `f(1` — the trailing argument goes before the `)` + let suite_start = 5; // just past the `:` + let suite_end = 31; // end of `raise E`, ahead of the trailing comment + assert_eq!(&source[..call_end], "f(1"); + assert_eq!( + &source[suite_start..suite_end], + "\n print(it)\n raise E" + ); + + let mut replacement = Replacement::default(); + replacement.push_generated("def _trailing_lambda_0(it=None):", 0); + replacement.push_source(source, suite_start, suite_end); + replacement.push_generated("\n", 0); + replacement.push_source(source, 0, call_end); + replacement.push_generated(", a=_trailing_lambda_0)", 0); + assert_eq!( + format!("{}{}", replacement.text(), &source[suite_end..]), + "def _trailing_lambda_0(it=None):\n print(it)\n raise E\nf(1, a=_trailing_lambda_0) # note\nprint(2)\n", + "the replacement is the text a plain string would have carried" + ); + + assert_eq!( + line_table(source, &[(0, suite_end, replacement)]), + vec![Some(0), Some(1), Some(2), Some(0), Some(3)], + "def header → the block statement; suite lines → themselves; the call → itself" + ); + } + + /// a line that opens with generated glue and goes on with copied text is + /// the copied text's line: the indentation ahead of a re-emitted call says + /// nothing, the call does + #[test] + fn copied_text_outranks_the_glue_around_it() { + let source = "x = 1\ny = 2\n"; + let mut replacement = Replacement::default(); + replacement.push_generated("pre\n ", 0); + replacement.push_source(source, 6, 11); + replacement.push_generated(" # gen", 0); + // the first output line is generated only, so it is the anchor's; the + // second opens with generated indentation but holds `y = 2`, copied + // from line 1 + assert_eq!( + line_table(source, &[(0, 11, replacement)]), + vec![Some(0), Some(1)] + ); + } + + /// a replacement's runs describe the same text a plain string would carry, + /// however the runs were pushed — contiguous copies and same-anchor + /// generated text merge, so two assemblies of one text compare equal + #[test] + fn runs_are_canonical() { + let source = "abcdef"; + let mut piecewise = Replacement::default(); + piecewise.push_source(source, 0, 2); + piecewise.push_source(source, 2, 4); + piecewise.push_generated("X", 4); + piecewise.push_generated("", 4); + piecewise.push_generated("Y", 4); + let mut whole = Replacement::default(); + whole.push_source(source, 0, 4); + whole.push_generated("XY", 4); + assert_eq!(piecewise, whole); + assert_eq!(whole.text(), "abcdXY"); + assert!(Replacement::default().text().is_empty()); + + // a copy that does not continue the last one is a run of its own, and + // the line it opens is charged to the first copy + let mut skipped = Replacement::default(); + skipped.push_source(source, 0, 2); + skipped.push_source(source, 3, 5); + assert_eq!(skipped.text(), "abde"); + assert_eq!(line_table("ab\ncd\ne", &[(0, 7, skipped)]), vec![Some(0)]); + } + + /// an insertion that adds whole lines ahead of a statement leaves that + /// statement mapped to itself + #[test] + fn an_insertion_does_not_shift_the_line_it_precedes() { + let source = "a = 1\nb = 2\n"; + let edits = [(6, 6, Replacement::generated("g = 0\n", 6))]; + assert_eq!(line_table(source, &edits), vec![Some(0), Some(1), Some(1)]); + } + + /// Every generated line beside the `.by` line it maps to, from the first + /// line that maps to source onwards: what precedes it is the import + /// preamble, whose length is not what these tests pin. + fn mapped_lines(source: &str) -> Vec<(Option, String)> { + let (db, file) = crate::make_in_memory_db(source); + let (output, map) = + crate::transpile_typed_with_map(&db, file, &crate::Config::test_default(), None) + .expect("transpile failed"); + assert_eq!( + map.len(), + output.lines().count(), + "one entry per generated line:\n{output}" + ); + let first = map + .iter() + .position(Option::is_some) + .expect("some line maps to source"); + output + .lines() + .zip(map) + .skip(first) + .map(|(line, mapped)| (mapped, line.to_owned())) + .collect() + } + + /// The whole map past the preamble, as `(.by line, generated text)` pairs: + /// every line has to be right, not only the one a test happens to look up, + /// because a traceback or a breakpoint can land on any of them. + #[track_caller] + fn assert_mapped(source: &str, expected: &[(u32, &str)]) { + let expected: Vec<(Option, String)> = expected + .iter() + .map(|&(line, text)| (Some(line), text.to_owned())) + .collect(); + assert_eq!(mapped_lines(source), expected); + } + + /// the shape the bug was reported on: a trailing-lambda block's suite is + /// hoisted into a `def` ahead of the call it hung off, and every line of + /// that `def` used to be charged to the statement that owns the block. the + /// generated header is that statement's, the body lines are their own, and + /// the re-emitted call is the statement's again — through three levels of + /// nesting and past a sibling block + #[test] + fn a_hoisted_block_maps_its_body_to_the_lines_it_came_from() { + assert_mapped( + indoc! {r#" + def column(content: () -> None): + content() + + def button(label: str, on_click: () -> None): + on_click() + + def app(): + column: + column: + button("a"): + raise ValueError("boom") + button("b"): + print("b") + print("after") + "#}, + &[ + (0, "def column(content: Callable[[], None]):"), + (1, " content()"), + (2, ""), + (3, "def button(label: str, on_click: Callable[[], None]):"), + (4, " on_click()"), + (5, ""), + (6, "def app():"), + (7, " def _trailing_lambda_0(it=None):"), + (8, " def _trailing_lambda_1(it=None):"), + (9, " def _trailing_lambda_2(it=None):"), + (10, " raise ValueError(\"boom\")"), + (9, " button(\"a\", on_click=_trailing_lambda_2)"), + (8, " column(content=_trailing_lambda_1)"), + (11, " def _trailing_lambda_3(it=None):"), + (12, " print(\"b\")"), + (11, " button(\"b\", on_click=_trailing_lambda_3)"), + (7, " column(content=_trailing_lambda_0)"), + (13, " print(\"after\")"), + ], + ); + } + + /// statements between nested blocks keep their own lines on either side of + /// the block they follow, three levels down and back up + #[test] + fn three_nested_blocks_each_map_to_their_own_lines() { + assert_mapped( + indoc! {r#" + def run(block: () -> None): + block() + + def app(): + run: + a = 1 + run: + b = 2 + run: + c = 3 + raise ValueError("deep") + d = 4 + e = 5 + print("after") + "#}, + &[ + (0, "def run(block: Callable[[], None]):"), + (1, " block()"), + (2, ""), + (3, "def app():"), + (4, " def _trailing_lambda_0(it=None):"), + (5, " a = 1"), + (6, " def _trailing_lambda_1(it=None):"), + (7, " b = 2"), + (8, " def _trailing_lambda_2(it=None):"), + (9, " c = 3"), + (10, " raise ValueError(\"deep\")"), + (8, " run(block=_trailing_lambda_2)"), + (11, " d = 4"), + (6, " run(block=_trailing_lambda_1)"), + (12, " e = 5"), + (4, " run(block=_trailing_lambda_0)"), + (13, " print(\"after\")"), + ], + ); + } + + /// a block inside a loop body is hoisted inside that body, and the loop + /// header ahead of it stays its own line + #[test] + fn a_block_inside_a_for_loop() { + assert_mapped( + indoc! {" + def button(label: str, on_click: () -> None): + on_click() + + def app(labels: list[str]): + for label in labels: + button(label): + print(label) + "}, + &[ + (0, "def button(label: str, on_click: Callable[[], None]):"), + (1, " on_click()"), + (2, ""), + (3, "def app(labels: list[str]):"), + (4, " for label in labels:"), + (5, " def _trailing_lambda_0(it=None):"), + (6, " print(label)"), + (5, " button(label, on_click=_trailing_lambda_0)"), + ], + ); + } + + /// `let row = it` inside a block is a within-line rewrite of a copied line, + /// so it stays on that line — with the trailing argument appended to a call + /// that had no keyword yet, and to one that already had a lambda keyword + #[test] + fn each_and_each_indexed_blocks_binding_it() { + assert_mapped( + indoc! {" + def each(items: list[int], fn: (int) -> None): + for item in items: + fn(item) + + def each_indexed(items: list[int], key: (int) -> int, fn: (int) -> None): + for item in items: + fn(key(item)) + + def app(items: list[int]): + each(items): + let row = it + print(row) + each_indexed(items, key=lambda item: item + 1): + let row = it + print(row) + "}, + &[ + (0, "def each(items: list[int], fn: Callable[[int], None]):"), + (1, " for item in items:"), + (2, " fn(item)"), + (3, ""), + ( + 4, + "def each_indexed(items: list[int], key: Callable[[int], int], fn: Callable[[int], None]):", + ), + (5, " for item in items:"), + (6, " fn(key(item))"), + (7, ""), + (8, "def app(items: list[int]):"), + (9, " def _trailing_lambda_0(it=None):"), + (10, " row: Final = it"), + (11, " print(row)"), + (9, " each(items, fn=_trailing_lambda_0)"), + (12, " def _trailing_lambda_1(it=None):"), + (13, " row: Final = it"), + (14, " print(row)"), + ( + 12, + " each_indexed(items, key=lambda item: item + 1, fn=_trailing_lambda_1)", + ), + ], + ); + } + + /// blank lines and comment lines inside the suite are copied with it and map + /// to themselves. a comment trailing the suite's last statement is outside + /// the span the lowering moves, so it lands after the re-emitted call, whose + /// line is the statement's + #[test] + fn a_block_body_with_blank_lines_and_comments() { + assert_mapped( + indoc! {r#" + def run(block: () -> None): + block() + + def app(): + run: + # first + a = 1 + + # second + b = 2 # trailing + print("after") + "#}, + &[ + (0, "def run(block: Callable[[], None]):"), + (1, " block()"), + (2, ""), + (3, "def app():"), + (4, " def _trailing_lambda_0(it=None):"), + (5, " # first"), + (6, " a = 1"), + (7, ""), + (8, " # second"), + (9, " b = 2"), + (4, " run(block=_trailing_lambda_0) # trailing"), + (10, " print(\"after\")"), + ], + ); + } + + /// a comment on the header line stays on the header line, which the `def` + /// stands for + #[test] + fn a_header_comment_stays_on_the_header_line() { + assert_mapped( + indoc! {" + def run(block: () -> None): + block() + + def app(): + run: # note + a = 1 + print(a) + "}, + &[ + (0, "def run(block: Callable[[], None]):"), + (1, " block()"), + (2, ""), + (3, "def app():"), + (4, " def _trailing_lambda_0(it=None): # note"), + (5, " a = 1"), + (4, " run(block=_trailing_lambda_0)"), + (6, " print(a)"), + ], + ); + } + + /// a call header spread over several lines is re-emitted line for line, and + /// the injected keyword — visible text of its own on the line the closing + /// paren sat on — is the owning statement's, not that paren's line + #[test] + fn a_multi_line_call_header_owning_a_block() { + assert_mapped( + indoc! {r#" + def button(label: str, enabled: bool, on_click: () -> None): + on_click() + + def app(): + button( + "reset", + enabled=True, + ): + print("clicked") + "#}, + &[ + ( + 0, + "def button(label: str, enabled: bool, on_click: Callable[[], None]):", + ), + (1, " on_click()"), + (2, ""), + (3, "def app():"), + (4, " def _trailing_lambda_0(it=None):"), + (8, " print(\"clicked\")"), + (4, " button("), + (5, " \"reset\","), + (6, " enabled=True,"), + (4, " on_click=_trailing_lambda_0)"), + ], + ); + } + + /// a lambda written as an argument on the header line is copied with the + /// call, and the block's keyword follows it + #[test] + fn a_lambda_argument_on_the_block_header_line() { + assert_mapped( + indoc! {r#" + def field(value: str, on_change: (str) -> None, on_submit: () -> None): + on_change("x") + on_submit() + + def app(): + field("v", on_change=lambda text: print(text)): + print("submitted") + "#}, + &[ + ( + 0, + "def field(value: str, on_change: Callable[[str], None], on_submit: Callable[[], None]):", + ), + (1, " on_change(\"x\")"), + (2, " on_submit()"), + (3, ""), + (4, "def app():"), + (5, " def _trailing_lambda_0(it=None):"), + (6, " print(\"submitted\")"), + ( + 5, + " field(\"v\", on_change=lambda text: print(text), on_submit=_trailing_lambda_0)", + ), + ], + ); + } + + /// the `nonlocal` a write-through block needs is synthesized on a line of + /// its own; nothing in the source spells it, so it is the owning statement's + #[test] + fn a_nonlocal_is_charged_to_the_statement_that_owns_the_block() { + assert_mapped( + indoc! {" + def with_resource(once fn: (int) -> None): + fn(42) + + def app() -> int: + total: int = 1 + with_resource: + total = it + return total + "}, + &[ + (0, "def with_resource(fn: Callable[[int], None]):"), + (1, " fn(42)"), + (2, ""), + (3, "def app() -> int:"), + (4, " total: int = 1"), + (5, " def _trailing_lambda_0(it=None):"), + (5, " nonlocal total"), + (6, " total = it"), + (5, " with_resource(fn=_trailing_lambda_0)"), + (7, " return total"), + ], + ); + } + + /// everything a `once` block's `return` needs — the value cell ahead of the + /// `def`, the pre-initialised fresh binding, the read-back after the call — + /// is synthesized for the statement; the rewritten `return` itself keeps the + /// returned expression, and with it its own line + #[test] + fn a_once_blocks_return_cell_is_charged_to_the_statement() { + assert_mapped( + indoc! {" + def with_resource(once fn: (int) -> None): + fn(42) + + def early() -> int: + with_resource: + doubled = it * 2 + return it + 1 + return doubled + "}, + &[ + (0, "def with_resource(fn: Callable[[int], None]):"), + (1, " fn(42)"), + (2, ""), + (3, "def early() -> int:"), + (4, " _trailing_lambda_0_return = []"), + (4, " doubled = None"), + (4, " def _trailing_lambda_0(it=None):"), + (4, " nonlocal doubled"), + (5, " doubled = it * 2"), + ( + 6, + " _trailing_lambda_0_return.append(it + 1); return", + ), + (4, " with_resource(fn=_trailing_lambda_0)"), + (4, " if _trailing_lambda_0_return:"), + (4, " return _trailing_lambda_0_return[0]"), + (7, " return doubled"), + ], + ); + } + + /// a block standing as an assignment's value hoists its `def` ahead of the + /// whole assignment, which is then re-emitted on its own line + #[test] + fn a_block_as_an_assignment_value() { + assert_mapped( + indoc! {r#" + def totalling(fn: (int) -> None) -> str: + fn(3) + return "done" + + def app() -> str: + outcome = totalling: + print(it) + return outcome + "#}, + &[ + (0, "def totalling(fn: Callable[[int], None]) -> str:"), + (1, " fn(3)"), + (2, " return \"done\""), + (3, ""), + (4, "def app() -> str:"), + (5, " def _trailing_lambda_0(it=None):"), + (6, " print(it)"), + (5, " outcome = totalling(fn=_trailing_lambda_0)"), + (7, " return outcome"), + ], + ); + } + + /// a statement an AST pass re-renders is printed from its AST, which keeps + /// no source ranges: nothing says which rendered line came from which source + /// line, so every line of it is charged to the statement's first line (see + /// the re-render edit in `ast_driver::run_against_source`). the repeated + /// `_` parameter is one such pass; the text after the statement resumes on + /// its own lines + #[test] + fn a_statement_re_rendered_from_its_ast_is_charged_whole_to_its_first_line() { + assert_mapped( + indoc! {" + def ignore(_: int, _: int) -> int: + a = 1 + return a + + print(ignore(1, 2)) + "}, + &[ + (0, "def ignore(_: int, _2: int) -> int:"), + (0, " a = 1"), + (0, " return a"), + (3, ""), + (4, "print(ignore(1, 2))"), + ], + ); + } } diff --git a/crates/by_transforms/src/transforms/ast_driver.rs b/crates/by_transforms/src/transforms/ast_driver.rs index ad5ab78225..9766d46ea2 100644 --- a/crates/by_transforms/src/transforms/ast_driver.rs +++ b/crates/by_transforms/src/transforms/ast_driver.rs @@ -52,6 +52,7 @@ use super::{ unpack, use_site_variance, }; use crate::Config; +use crate::source_map::Replacement; use crate::type_info::TypeInfo; /// Holds the db backing the type-aware passes. `Local` owns a single-file @@ -249,16 +250,22 @@ fn template_claimees( /// Materialize a template's fragments into `out`. `Src` passthrough spans are /// emitted from original source with the contained sub-edits (indices into /// `all`) applied. +/// +/// `anchor` is the start of the edit the template belongs to. Its literal text +/// is charged to that offset in the line table: a hoisted `def` header, an +/// injected keyword argument, a `nonlocal` line — none of them has a line of +/// its own, and the construct the edit rewrites is the one they stand for. fn materialize_fragments( - out: &mut String, + out: &mut Replacement, frags: &[Fragment], source: &str, all: &[(usize, usize, SubPatch)], contained: &[usize], + anchor: usize, ) { for (i, frag) in frags.iter().enumerate() { match frag { - Fragment::Lit(s) => out.push_str(s), + Fragment::Lit(s) => out.push_generated(s, anchor), Fragment::Src(span) => { // a zero-width insertion at this span's end is normally deferred // to the *next* `Src` span (which re-emits it at its start), so @@ -296,7 +303,7 @@ fn materialize_fragments( /// whether a zero-width insertion exactly at `e0` is emitted here (see /// [`materialize_fragments`]). fn apply_within( - out: &mut String, + out: &mut Replacement, source: &str, s0: usize, e0: usize, @@ -315,22 +322,22 @@ fn apply_within( k += 1; continue; } - out.push_str(&source[cursor..s]); + out.push_source(source, cursor, s); match &all[idx].2 { - SubPatch::Text(t) => out.push_str(t), + SubPatch::Text(t) => out.push_generated(t, s), SubPatch::Template(frags) | SubPatch::Statement(frags) => { let inner: Vec = contained[k + 1..] .iter() .copied() .filter(|&m| all[m].0 >= s && all[m].1 <= e && all[m].0 != e) .collect(); - materialize_fragments(out, frags, source, all, &inner); + materialize_fragments(out, frags, source, all, &inner, s); } } cursor = cursor.max(e); k += 1; } - out.push_str(&source[cursor..e0]); + out.push_source(source, cursor, e0); } /// Coalesce repeated `from import X` lines into a single @@ -1001,7 +1008,7 @@ pub(crate) fn run_against_source<'a>( occupied_ranges.iter().any(|(s, e)| start < *e && *s < end) }; - let mut edits: Vec<(usize, usize, String)> = Vec::new(); + let mut edits: Vec<(usize, usize, Replacement)> = Vec::new(); for idx in all_idx.iter().copied() { let (start, end) = original_ranges[idx]; let line_indent = { @@ -1033,11 +1040,13 @@ pub(crate) fn run_against_source<'a>( } else { block.push_str(&rendered); } - edits.push((start, end, block)); + // a re-rendered statement is printed from its AST, which keeps no + // source ranges: every line of it is charged to the statement + edits.push((start, end, Replacement::generated(&block, start))); } else if !block.is_empty() { // hoist-only: insert the hoisted lines before the statement and // leave its source bytes (and any edits inside them) in place - edits.push((start, start, block)); + edits.push((start, start, Replacement::generated(&block, start))); } } // ruff-style first-wins dedup for sub-statement edits. sort by start; skip @@ -1167,12 +1176,12 @@ pub(crate) fn run_against_source<'a>( // pass pushes its slice in left-to-right intent order, and we // splice them as one contiguous string if end == start { - let mut combined = String::new(); + let mut combined = Replacement::default(); let mut j = i; while j < sub_edits.len() && sub_edits[j].0 == start && sub_edits[j].1 == start { if !claimed[j] { match &sub_edits[j].2 { - SubPatch::Text(t) => combined.push_str(t), + SubPatch::Text(t) => combined.push_generated(t, start), SubPatch::Template(frags) | SubPatch::Statement(frags) => { let contained = template_claimees(frags, &sub_edits, &claimed, j, None); materialize_fragments( @@ -1181,6 +1190,7 @@ pub(crate) fn run_against_source<'a>( source_ref, &sub_edits, &contained, + start, ); } } @@ -1193,14 +1203,14 @@ pub(crate) fn run_against_source<'a>( } let repl = match &sub_edits[i].2 { // a plain-text replacement wins over anything inside it - SubPatch::Text(t) => t.clone(), + SubPatch::Text(t) => Replacement::generated(t, start), SubPatch::Template(frags) | SubPatch::Statement(frags) => { // the claimees nested in this span materialize inside the // template's `Src` passthrough fragments let contained = template_claimees(frags, &sub_edits, &claimed, i, Some((start, end))); - let mut out = String::new(); - materialize_fragments(&mut out, frags, source_ref, &sub_edits, &contained); + let mut out = Replacement::default(); + materialize_fragments(&mut out, frags, source_ref, &sub_edits, &contained, start); out } }; @@ -1215,8 +1225,8 @@ pub(crate) fn run_against_source<'a>( // rather than letting it surface as a confusing syntax error downstream for (start, end) in dropped_by_splice { let construct = &source_ref[start..end]; - let leaked = edits.iter().any(|(bs, be, btext)| { - *be > *bs && *bs <= start && end <= *be && btext.contains(construct) + let leaked = edits.iter().any(|(bs, be, replacement)| { + *be > *bs && *bs <= start && end <= *be && replacement.text().contains(construct) }); if leaked { let preview: String = construct.chars().take(40).collect(); @@ -1240,7 +1250,7 @@ pub(crate) fn run_against_source<'a>( let mut out = source_ref.to_owned(); for (start, end, repl) in edits { - out.replace_range(start..end, &repl); + out.replace_range(start..end, repl.text()); } // an entry may be multi-line (runtime helper defs), so the table prefix // counts the lines each entry emits, not the entries themselves @@ -1350,9 +1360,9 @@ mod driver_tests { Fragment::Src(TextRange::new(TextSize::from(0u32), TextSize::from(3u32))), Fragment::Lit("Y".to_owned()), ]; - let mut out = String::new(); - materialize_fragments(&mut out, &frags, source, &all, &[0]); - assert_eq!(out, "[1])Y"); + let mut out = Replacement::default(); + materialize_fragments(&mut out, &frags, source, &all, &[0], 0); + assert_eq!(out.text(), "[1])Y"); } /// between two adjacent `Src` spans the shared boundary insertion is @@ -1366,8 +1376,8 @@ mod driver_tests { Fragment::Src(TextRange::new(TextSize::from(0u32), TextSize::from(3u32))), Fragment::Src(TextRange::new(TextSize::from(3u32), TextSize::from(4u32))), ]; - let mut out = String::new(); - materialize_fragments(&mut out, &frags, source, &all, &[0]); - assert_eq!(out, "[1])W"); + let mut out = Replacement::default(); + materialize_fragments(&mut out, &frags, source, &all, &[0], 0); + assert_eq!(out.text(), "[1])W"); } } diff --git a/crates/ty/tests/by_e2e.rs b/crates/ty/tests/by_e2e.rs index 6beedbb68e..00993a4658 100644 --- a/crates/ty/tests/by_e2e.rs +++ b/crates/ty/tests/by_e2e.rs @@ -484,6 +484,65 @@ fn run_still_rewrites_traceback_frames_to_by_lines() { ); } +/// a frame inside a trailing-lambda block is reported at the block's own `.by` +/// line. the block's suite is hoisted into a `def` ahead of the call it hung +/// off, and the map used to charge every line of that `def` — header and body +/// alike — to the statement owning the block, so an exception raised in a +/// handler named the widget call instead of the line that raised +#[test] +fn run_reports_a_handler_blocks_own_line_for_an_exception_raised_in_it() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write( + dir.path().join("main.by"), + "\ +def handle(fn: () -> None): + fn() + +def main(): + handle: + print(\"handling\") + raise ValueError(\"boom\") + +main() +", + ) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_by")) + .args(["run", "main"]) + .current_dir(dir.path()) + .output() + .expect("failed to spawn by"); + + assert!(!output.status.success(), "expected a non-zero exit"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("main.by\", line 7, in _trailing_lambda_0") + && stderr.contains("raise ValueError(\"boom\")"), + "the raise inside the block should map to its own .by line 7:\n{stderr}" + ); + assert!( + stderr.contains("main.by\", line 2, in handle") && stderr.contains("fn()"), + "the callee's frame should map to .by line 2:\n{stderr}" + ); + assert!( + stderr.contains("main.by\", line 5, in main") && stderr.contains("handle:"), + "the statement owning the block should map to .by line 5:\n{stderr}" + ); + assert!( + stderr.contains("main.by\", line 9, in "), + "the module-level call should map to .by line 9:\n{stderr}" + ); + assert!( + !stderr.contains(".py\""), + "traceback should not leak generated .py paths:\n{stderr}" + ); + assert!( + stderr.contains("ValueError: boom"), + "exception type should be preserved:\n{stderr}" + ); +} + /// inference recurses with the shape of the expression it is checking, and `run` /// checks on the thread it was dispatched to rather than through the rayon pool. /// on the stack a process starts with — 1 MiB on windows — a file like this one From 3a9ac48188efe977801c0442c662eb38bd25de0c Mon Sep 17 00:00:00 2001 From: KotlinIsland <65446343+kotlinisland@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:04:15 +1000 Subject: [PATCH 11/11] infer a generic function's raises clause once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a generic function's signature is inferred in its type-parameter scope, and a non-generic one's is deferred to the enclosing scope. both go through `infer_function_signature_annotations`, which infers the `raises` clause — and `infer_function_type_params` inferred it a second time on the way there. `raises ...` is where that shows: the ellipsis is the gradual exception set rather than a type expression, so it is inferred as a plain value, and storing one expression's type twice in a region trips an assertion. any generic function with `raises ...` panicked the checker. the duplicate call came in with the upstream merge in 0cd20e6bc603, which added the call inside `infer_function_signature_annotations` without dropping the existing one in its caller. main panics on def make[T](initial: T) -> int raises ...: return 1 Co-Authored-By: Claude Opus 5 --- .../mdtest/basedpython_exceptions.md | 21 +++++++++++++++++++ .../src/types/infer/builder/function.rs | 1 - 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/ty_python_semantic/resources/mdtest/basedpython_exceptions.md b/crates/ty_python_semantic/resources/mdtest/basedpython_exceptions.md index 058136b949..463f28610c 100644 --- a/crates/ty_python_semantic/resources/mdtest/basedpython_exceptions.md +++ b/crates/ty_python_semantic/resources/mdtest/basedpython_exceptions.md @@ -183,6 +183,27 @@ def main(): anything(False) ``` +## a generic function's clause is inferred once + +A generic function's signature is inferred in its type-parameter scope, while a non-generic one's is +deferred to the enclosing scope. Both reach the same inference, so the clause must not also be +inferred on the way there. `raises ...` is the case that shows it: the ellipsis is the gradual +exception set rather than a type expression, so it is inferred as the plain value it is, and +inferring one expression twice in a single region is a hard error. + +```by +def gradual[T](value: T) -> T raises ...: + raise TypeError +``` + +The clause still reaches the check on the body, which is what says it was inferred at all. + +```by +def declared[T](value: T) -> T raises TypeError: + # error: [undeclared-raise] "`declared` can raise `ValueError`, which its `raises` clause does not include" + raise ValueError +``` + ## a negated clause is checked, but strictly `not TypeError` is the ordinary negation type, and the body is checked against it with ordinary diff --git a/crates/ty_python_semantic/src/types/infer/builder/function.rs b/crates/ty_python_semantic/src/types/infer/builder/function.rs index 0a6b8d9bbe..b5f73395da 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/function.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/function.rs @@ -1384,7 +1384,6 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { let previous_typevar_binding_context = self.typevar_binding_context.replace(binding_context); self.infer_function_signature_annotations(function, binding_context); - self.infer_raises_clause(function); self.typevar_binding_context = previous_typevar_binding_context; }