Compare commits

...

3 Commits

Author SHA1 Message Date
Micha Reiser
308373124d [ty] Make TrackedConstraintSet interned 2026-01-13 12:51:45 +01:00
Dhruv Manilawala
990d0a8999 [ty] Improve log guidance message for Zed (#22530)
## Summary

closes: https://github.com/astral-sh/ty/issues/1717

## Test Plan


https://github.com/user-attachments/assets/5d8d6d03-3451-4403-a6cd-2deba9e796fc
2026-01-13 08:13:11 +00:00
Carl Meyer
99beabdde8 [ty] Fix false positive for bounded type parameters with NewType (#22542)
Fixes https://github.com/astral-sh/ty/issues/2467

When calling a method on an instance of a generic class with bounded
type parameters (e.g., `C[T: K]` where `K` is a NewType), ty was
incorrectly reporting: "Argument type `C[K]` does not satisfy upper
bound `C[T@C]` of type variable `Self`"

The issue was introduced by PR #22105, which moved the catch-all case
for NewType assignments that falls back to the concrete base type. This
case was moved before the TypeVar handling cases, so when checking `K <:
T@C` (where K is a NewType and T@C is a TypeVar with upper bound K):

1. The NewType fallback matched first
2. It delegated to `int` (K's concrete base type)
3. Then checked `int <: T@C`, which checks if `int` satisfies bound `K`
4. But `int` is not assignable to `K` (NewTypes are distinct from their
bases)

The fix moves the NewType fallback case after the TypeVar cases, so
TypeVar handling takes precedence. Now when checking `K <: T@C`, we use
the TypeVar case at line 828 which returns `false` for non-inferable
typevars - but this is correct because the *other* direction (`T@C <:
K`) passes, and for the overall specialization comparison both
directions are checked.
2026-01-12 17:23:31 -08:00
7 changed files with 145 additions and 35 deletions

View File

@@ -456,6 +456,40 @@ reveal_type(int_container) # revealed: Container[int]
reveal_type(int_container.set_value(1)) # revealed: Container[int]
```
## Generic class with bounded type variable
This is a regression test for <https://github.com/astral-sh/ty/issues/2467>.
Calling a method on a generic class instance should work when the type parameter is specialized with
a type that satisfies a bound.
```py
from typing import NewType
class Base: ...
class C[T: Base]:
x: T
def g(self) -> None:
pass
# Calling a method on a specialized instance should not produce an error
C[Base]().g()
# Test with a NewType bound
K = NewType("K", int)
class D[T: K]:
x: T
def h(self) -> None:
pass
# Calling a method on a specialized instance should not produce an error
D[K]().h()
```
## Protocols
TODO: <https://typing.python.org/en/latest/spec/generics.html#use-in-protocols>

View File

@@ -6942,7 +6942,7 @@ impl<'db> TypeMapping<'_, 'db> {
/// put in a [`KnownInstance::ConstraintSet`]. We don't actually manipulate these as part of using
/// constraint sets to check things like assignability; they're only used as a debugging aid in
/// mdtests. That means there's no need for this to be interned; being tracked is sufficient.
#[salsa::tracked(debug, heap_size=ruff_memory_usage::heap_size)]
#[salsa::interned(debug, heap_size=ruff_memory_usage::heap_size)]
#[derive(PartialOrd, Ord)]
pub struct TrackedConstraintSet<'db> {
constraints: ConstraintSet<'db>,

View File

@@ -715,17 +715,6 @@ impl<'db> Type<'db> {
}
})
}
// All other `NewType` assignments fall back to the concrete base type.
(Type::NewTypeInstance(self_newtype), _) => {
self_newtype.concrete_base_type(db).has_relation_to_impl(
db,
target,
inferable,
relation,
relation_visitor,
disjointness_visitor,
)
}
(Type::Union(union), _) => union.elements(db).iter().when_all(db, |&elem_ty| {
elem_ty.has_relation_to_impl(
@@ -869,6 +858,21 @@ impl<'db> Type<'db> {
ConstraintSet::from(false)
}
// All other `NewType` assignments fall back to the concrete base type.
// This case must come after the TypeVar cases above, so that when checking
// `NewType <: TypeVar`, we use the TypeVar handling rather than falling back
// to the NewType's concrete base type.
(Type::NewTypeInstance(self_newtype), _) => {
self_newtype.concrete_base_type(db).has_relation_to_impl(
db,
target,
inferable,
relation,
relation_visitor,
disjointness_visitor,
)
}
// Note that the definition of `Type::AlwaysFalsy` depends on the return value of `__bool__`.
// If `__bool__` always returns True or False, it can be treated as a subtype of `AlwaysTruthy` or `AlwaysFalsy`, respectively.
(left, Type::AlwaysFalsy) => ConstraintSet::from(left.bool(db).is_always_false()),

View File

@@ -3,7 +3,7 @@
use self::schedule::spawn_main_loop;
use crate::PositionEncoding;
use crate::capabilities::{ResolvedClientCapabilities, server_capabilities};
use crate::session::{InitializationOptions, Session, warn_about_unknown_options};
use crate::session::{ClientName, InitializationOptions, Session, warn_about_unknown_options};
use anyhow::Context;
use lsp_server::Connection;
use lsp_types::{ClientCapabilities, InitializeParams, MessageType, Url};
@@ -47,6 +47,7 @@ impl Server {
initialization_options,
capabilities: client_capabilities,
workspace_folders,
client_info,
..
} = serde_json::from_value(init_value)
.context("Failed to deserialize initialization parameters")?;
@@ -65,6 +66,7 @@ impl Server {
tracing::error!("Failed to deserialize initialization options: {error}");
}
tracing::debug!("Client info: {client_info:#?}");
tracing::debug!("Initialization options: {initialization_options:#?}");
let resolved_client_capabilities = ResolvedClientCapabilities::new(&client_capabilities);
@@ -155,6 +157,7 @@ impl Server {
workspace_urls,
initialization_options,
native_system,
ClientName::from(client_info),
in_test,
)?,
})

View File

@@ -119,9 +119,12 @@ pub(super) fn request(req: server::Request) -> Task {
.unwrap_or_else(|err| {
tracing::error!("Encountered error when routing request with ID {id}: {err}");
Task::sync(move |_session, client| {
Task::sync(move |session, client| {
if matches!(err.code, ErrorCode::InternalError) {
client.show_error_message("ty failed to handle a request from the editor. Check the logs for more details.");
client.show_error_message(format!(
"ty failed to handle a request from the editor. {}",
session.client_name().log_guidance()
));
}
respond_silent_error(
@@ -175,11 +178,12 @@ pub(super) fn notification(notif: server::Notification) -> Task {
}
.unwrap_or_else(|err| {
tracing::error!("Encountered error when routing notification: {err}");
Task::sync(move |_session, client| {
Task::sync(move |session, client| {
if matches!(err.code, ErrorCode::InternalError) {
client.show_error_message(
"ty failed to handle a notification from the editor. Check the logs for more details."
);
client.show_error_message(format!(
"ty failed to handle a notification from the editor. {}",
session.client_name().log_guidance()
));
}
})
})
@@ -193,7 +197,7 @@ where
Ok(Task::sync(move |session, client: &Client| {
let _span = tracing::debug_span!("request", %id, method = R::METHOD).entered();
let result = R::run(session, client, params);
respond::<R>(&id, result, client);
respond::<R>(&id, result, client, session.client_name().log_guidance());
}))
}
@@ -217,6 +221,7 @@ where
// SAFETY: The `snapshot` is safe to move across the unwind boundary because it is not used
// after unwinding.
let snapshot = AssertUnwindSafe(session.snapshot_session());
let log_guidance = snapshot.0.client_name().log_guidance();
Box::new(move |client| {
let _span = tracing::debug_span!("request", %id, method = R::METHOD).entered();
@@ -238,7 +243,7 @@ where
let snapshot = snapshot;
R::handle_request(&id, snapshot.0, client, params);
}) {
panic_response::<R>(&id, client, &error, retry);
panic_response::<R>(&id, client, &error, retry, log_guidance);
}
})
}))
@@ -284,6 +289,7 @@ where
let path = document.notebook_or_file_path();
let db = session.project_db(path).clone();
let log_guidance = document.client_name().log_guidance();
Box::new(move |client| {
let _span = tracing::debug_span!("request", %id, method = R::METHOD).entered();
@@ -306,7 +312,7 @@ where
R::handle_request(&id, &db, document, client, params);
});
}) {
panic_response::<R>(&id, client, &error, retry);
panic_response::<R>(&id, client, &error, retry, log_guidance);
}
})
}))
@@ -317,6 +323,7 @@ fn panic_response<R>(
client: &Client,
error: &PanicError,
request: Option<lsp_server::Request>,
log_guidance: &str,
) where
R: traits::RetriableRequestHandler,
{
@@ -346,6 +353,7 @@ fn panic_response<R>(
error: anyhow!("request handler {error}"),
}),
client,
log_guidance,
);
}
}
@@ -358,7 +366,10 @@ fn sync_notification_task<N: traits::SyncNotificationHandler>(
let _span = tracing::debug_span!("notification", method = N::METHOD).entered();
if let Err(err) = N::run(session, client, params) {
tracing::error!("An error occurred while running {id}: {err}");
client.show_error_message("ty encountered a problem. Check the logs for more details.");
client.show_error_message(format!(
"ty encountered a problem. {}",
session.client_name().log_guidance()
));
return;
}
@@ -390,6 +401,8 @@ where
return Box::new(|_| {});
};
let log_guidance = snapshot.client_name().log_guidance();
Box::new(move |client| {
let _span = tracing::debug_span!("notification", method = N::METHOD).entered();
@@ -399,18 +412,14 @@ where
Ok(result) => result,
Err(panic) => {
tracing::error!("An error occurred while running {id}: {panic}");
client.show_error_message(
"ty encountered a panic. Check the logs for more details.",
);
client.show_error_message(format!("ty encountered a panic. {log_guidance}"));
return;
}
};
if let Err(err) = result {
tracing::error!("An error occurred while running {id}: {err}");
client.show_error_message(
"ty encountered a problem. Check the logs for more details.",
);
client.show_error_message(format!("ty encountered a problem. {log_guidance}"));
}
})
}))
@@ -449,12 +458,13 @@ fn respond<Req>(
id: &RequestId,
result: Result<<<Req as RequestHandler>::RequestType as Request>::Result>,
client: &Client,
log_guidance: &str,
) where
Req: RequestHandler,
{
if let Err(err) = &result {
tracing::error!("An error occurred with request ID {id}: {err}");
client.show_error_message("ty encountered a problem. Check the logs for more details.");
client.show_error_message(format!("ty encountered a problem. {log_guidance}"));
}
client.respond(id, result);
}

View File

@@ -116,7 +116,10 @@ pub(super) trait BackgroundDocumentRequestHandler: RetriableRequestHandler {
if let Err(err) = &result {
tracing::error!("An error occurred with request ID {id}: {err}");
client.show_error_message("ty encountered a problem. Check the logs for more details.");
client.show_error_message(format!(
"ty encountered a problem. {}",
snapshot.client_name().log_guidance()
));
}
client.respond(id, result);
@@ -153,7 +156,10 @@ pub(super) trait BackgroundRequestHandler: RetriableRequestHandler {
if let Err(err) = &result {
tracing::error!("An error occurred with request ID {id}: {err}");
client.show_error_message("ty encountered a problem. Check the logs for more details.");
client.show_error_message(format!(
"ty encountered a problem. {}",
snapshot.client_name().log_guidance()
));
}
client.respond(id, result);

View File

@@ -13,7 +13,7 @@ use lsp_types::request::{
WorkspaceDiagnosticRequest,
};
use lsp_types::{
DiagnosticRegistrationOptions, DiagnosticServerCapabilities,
ClientInfo, DiagnosticRegistrationOptions, DiagnosticServerCapabilities,
DidChangeWatchedFilesRegistrationOptions, FileSystemWatcher, Registration, RegistrationParams,
TextDocumentContentChangeEvent, Unregistration, UnregistrationParams, Url,
};
@@ -106,6 +106,9 @@ pub(crate) struct Session {
/// Registrations is a set of LSP methods that have been dynamically registered with the
/// client.
registrations: HashSet<String>,
/// The name of the client (editor) that connected to this server.
client_name: ClientName,
}
/// LSP State for a Project
@@ -141,6 +144,7 @@ impl Session {
workspace_urls: Vec<Url>,
initialization_options: InitializationOptions,
native_system: Arc<dyn System + 'static + Send + Sync + RefUnwindSafe>,
client_name: ClientName,
in_test: bool,
) -> crate::Result<Self> {
let index = Arc::new(Index::new());
@@ -168,6 +172,7 @@ impl Session {
suspended_workspace_diagnostics_request: None,
revision: 0,
registrations: HashSet::new(),
client_name,
})
}
@@ -532,8 +537,8 @@ impl Session {
);
client.show_error_message(format!(
"Failed to load project for workspace {url}. \
Please refer to the logs for more details.",
"Failed to load project for workspace {url}. {}",
self.client_name.log_guidance(),
));
let db_with_default_settings = ProjectMetadata::from_options(
@@ -819,6 +824,7 @@ impl Session {
.unwrap_or_else(|| Arc::new(WorkspaceSettings::default())),
position_encoding: self.position_encoding,
document: document_handle,
client_name: self.client_name,
})
}
@@ -837,6 +843,7 @@ impl Session {
in_test: self.in_test,
resolved_client_capabilities: self.resolved_client_capabilities,
revision: self.revision,
client_name: self.client_name,
}
}
@@ -976,6 +983,10 @@ impl Session {
pub(crate) fn position_encoding(&self) -> PositionEncoding {
self.position_encoding
}
pub(crate) fn client_name(&self) -> ClientName {
self.client_name
}
}
/// A guard that holds the only reference to the index and allows modifying it.
@@ -1025,6 +1036,7 @@ pub(crate) struct DocumentSnapshot {
workspace_settings: Arc<WorkspaceSettings>,
position_encoding: PositionEncoding,
document: DocumentHandle,
client_name: ClientName,
}
impl DocumentSnapshot {
@@ -1071,6 +1083,10 @@ impl DocumentSnapshot {
pub(crate) fn notebook_or_file_path(&self) -> &AnySystemPath {
self.document.notebook_or_file_path()
}
pub(crate) fn client_name(&self) -> ClientName {
self.client_name
}
}
/// An immutable snapshot of the current state of [`Session`].
@@ -1081,6 +1097,7 @@ pub(crate) struct SessionSnapshot {
resolved_client_capabilities: ResolvedClientCapabilities,
in_test: bool,
revision: u64,
client_name: ClientName,
/// IMPORTANT: It's important that the databases come last, or at least,
/// after any `Arc` that we try to extract or mutate in-place using `Arc::into_inner`
@@ -1122,6 +1139,42 @@ impl SessionSnapshot {
pub(crate) fn revision(&self) -> u64 {
self.revision
}
pub(crate) fn client_name(&self) -> ClientName {
self.client_name
}
}
/// Represents the client (editor) that's connected to the language server.
#[derive(Debug, Clone, Copy)]
pub(crate) enum ClientName {
Zed,
Other,
}
impl From<Option<ClientInfo>> for ClientName {
fn from(info: Option<ClientInfo>) -> Self {
match info {
Some(info) if matches!(info.name.as_str(), "Zed") => ClientName::Zed,
_ => ClientName::Other,
}
}
}
impl ClientName {
/// Returns editor-specific guidance for finding logs.
///
/// Different editors have different ways to access language server logs, so we provide tailored
/// instructions based on the connected client.
pub(crate) fn log_guidance(self) -> &'static str {
match self {
ClientName::Zed => {
"Please refer to the logs for more details \
(command palette: `dev: open language server logs`)."
}
ClientName::Other => "Please refer to the logs for more details.",
}
}
}
#[derive(Debug, Default)]