Compare commits

..

1 Commits

Author SHA1 Message Date
Aria Desires
b291969813 proof of concept: clickable types in hover 2025-12-15 14:18:45 -05:00
11 changed files with 408 additions and 668 deletions

View File

@@ -166,9 +166,8 @@ fn emit_field(output: &mut String, name: &str, field: &OptionField, parents: &[S
output.push('\n');
let _ = writeln!(output, "**Type**: `{}`", field.value_type);
output.push('\n');
output.push_str("**Example usage**:\n\n");
output.push_str("**Example usage** (`pyproject.toml`):\n\n");
output.push_str(&format_example(
"pyproject.toml",
&format_header(
field.scope,
field.example,
@@ -180,11 +179,11 @@ fn emit_field(output: &mut String, name: &str, field: &OptionField, parents: &[S
output.push('\n');
}
fn format_example(title: &str, header: &str, content: &str) -> String {
fn format_example(header: &str, content: &str) -> String {
if header.is_empty() {
format!("```toml title=\"{title}\"\n{content}\n```\n",)
format!("```toml\n{content}\n```\n",)
} else {
format!("```toml title=\"{title}\"\n{header}\n{content}\n```\n",)
format!("```toml\n{header}\n{content}\n```\n",)
}
}

View File

@@ -18,9 +18,9 @@ Valid severities are:
**Type**: `dict[RuleName, "ignore" | "warn" | "error"]`
**Example usage**:
**Example usage** (`pyproject.toml`):
```toml title="pyproject.toml"
```toml
[tool.ty.rules]
possibly-unresolved-reference = "warn"
division-by-zero = "ignore"
@@ -45,9 +45,9 @@ configuration setting.
**Type**: `list[str]`
**Example usage**:
**Example usage** (`pyproject.toml`):
```toml title="pyproject.toml"
```toml
[tool.ty.environment]
extra-paths = ["./shared/my-search-path"]
```
@@ -76,9 +76,9 @@ This option can be used to point to virtual or system Python environments.
**Type**: `str`
**Example usage**:
**Example usage** (`pyproject.toml`):
```toml title="pyproject.toml"
```toml
[tool.ty.environment]
python = "./custom-venv-location/.venv"
```
@@ -103,9 +103,9 @@ If no platform is specified, ty will use the current platform:
**Type**: `"win32" | "darwin" | "android" | "ios" | "linux" | "all" | str`
**Example usage**:
**Example usage** (`pyproject.toml`):
```toml title="pyproject.toml"
```toml
[tool.ty.environment]
# Tailor type stubs and conditionalized type definitions to windows.
python-platform = "win32"
@@ -137,9 +137,9 @@ to reflect the differing contents of the standard library across Python versions
**Type**: `"3.7" | "3.8" | "3.9" | "3.10" | "3.11" | "3.12" | "3.13" | "3.14" | <major>.<minor>`
**Example usage**:
**Example usage** (`pyproject.toml`):
```toml title="pyproject.toml"
```toml
[tool.ty.environment]
python-version = "3.12"
```
@@ -165,9 +165,9 @@ it will also be included in the first party search path.
**Type**: `list[str]`
**Example usage**:
**Example usage** (`pyproject.toml`):
```toml title="pyproject.toml"
```toml
[tool.ty.environment]
# Multiple directories (priority order)
root = ["./src", "./lib", "./vendor"]
@@ -185,9 +185,9 @@ bundled as a zip file in the binary
**Type**: `str`
**Example usage**:
**Example usage** (`pyproject.toml`):
```toml title="pyproject.toml"
```toml
[tool.ty.environment]
typeshed = "/path/to/custom/typeshed"
```
@@ -240,9 +240,9 @@ If not specified, defaults to `[]` (excludes no files).
**Type**: `list[str]`
**Example usage**:
**Example usage** (`pyproject.toml`):
```toml title="pyproject.toml"
```toml
[[tool.ty.overrides]]
exclude = [
"generated",
@@ -268,9 +268,9 @@ If not specified, defaults to `["**"]` (matches all files).
**Type**: `list[str]`
**Example usage**:
**Example usage** (`pyproject.toml`):
```toml title="pyproject.toml"
```toml
[[tool.ty.overrides]]
include = [
"src",
@@ -292,9 +292,9 @@ severity levels or disable them entirely.
**Type**: `dict[RuleName, "ignore" | "warn" | "error"]`
**Example usage**:
**Example usage** (`pyproject.toml`):
```toml title="pyproject.toml"
```toml
[[tool.ty.overrides]]
include = ["src"]
@@ -358,9 +358,9 @@ to re-include `dist` use `exclude = ["!dist"]`
**Type**: `list[str]`
**Example usage**:
**Example usage** (`pyproject.toml`):
```toml title="pyproject.toml"
```toml
[tool.ty.src]
exclude = [
"generated",
@@ -399,9 +399,9 @@ matches `<project_root>/src` and not `<project_root>/test/src`).
**Type**: `list[str]`
**Example usage**:
**Example usage** (`pyproject.toml`):
```toml title="pyproject.toml"
```toml
[tool.ty.src]
include = [
"src",
@@ -421,9 +421,9 @@ Enabled by default.
**Type**: `bool`
**Example usage**:
**Example usage** (`pyproject.toml`):
```toml title="pyproject.toml"
```toml
[tool.ty.src]
respect-ignore-files = false
```
@@ -450,9 +450,9 @@ it will also be included in the first party search path.
**Type**: `str`
**Example usage**:
**Example usage** (`pyproject.toml`):
```toml title="pyproject.toml"
```toml
[tool.ty.src]
root = "./app"
```
@@ -471,9 +471,9 @@ Defaults to `false`.
**Type**: `bool`
**Example usage**:
**Example usage** (`pyproject.toml`):
```toml title="pyproject.toml"
```toml
[tool.ty.terminal]
# Error if ty emits any warning-level diagnostics.
error-on-warning = true
@@ -491,9 +491,9 @@ Defaults to `full`.
**Type**: `full | concise`
**Example usage**:
**Example usage** (`pyproject.toml`):
```toml title="pyproject.toml"
```toml
[tool.ty.terminal]
output-format = "concise"
```

View File

@@ -1,12 +1,12 @@
use crate::docstring::Docstring;
use crate::goto::{GotoTarget, find_goto_target};
use crate::{Db, MarkupKind, RangedValue};
use crate::{Db, HasNavigationTargets, MarkupKind, NavigationTarget, RangedValue};
use ruff_db::files::{File, FileRange};
use ruff_db::parsed::parsed_module;
use ruff_text_size::{Ranged, TextSize};
use std::fmt;
use std::fmt::Formatter;
use ty_python_semantic::types::{KnownInstanceType, Type, TypeVarVariance};
use ty_python_semantic::types::{KnownInstanceType, Type, TypeDetail, TypeVarVariance};
use ty_python_semantic::{DisplaySettings, SemanticModel};
pub fn hover(db: &dyn Db, file: File, offset: TextSize) -> Option<RangedValue<Hover<'_>>> {
@@ -59,13 +59,21 @@ pub struct Hover<'db> {
contents: Vec<HoverContent<'db>>,
}
type Linkify<'a> = &'a dyn Fn(&NavigationTarget) -> Option<String>;
impl<'db> Hover<'db> {
/// Renders the hover to a string using the specified markup kind.
pub const fn display<'a>(&'a self, db: &'db dyn Db, kind: MarkupKind) -> DisplayHover<'db, 'a> {
pub const fn display<'a>(
&'a self,
db: &'db dyn Db,
kind: MarkupKind,
linkify: Linkify<'a>,
) -> DisplayHover<'db, 'a> {
DisplayHover {
db,
hover: self,
kind,
linkify,
}
}
@@ -96,6 +104,7 @@ pub struct DisplayHover<'db, 'a> {
db: &'db dyn Db,
hover: &'a Hover<'db>,
kind: MarkupKind,
linkify: Linkify<'a>,
}
impl fmt::Display for DisplayHover<'_, '_> {
@@ -106,7 +115,7 @@ impl fmt::Display for DisplayHover<'_, '_> {
self.kind.horizontal_line().fmt(f)?;
}
content.display(self.db, self.kind).fmt(f)?;
content.display(self.db, self.kind, self.linkify).fmt(f)?;
first = false;
}
@@ -122,11 +131,17 @@ pub enum HoverContent<'db> {
}
impl<'db> HoverContent<'db> {
fn display(&self, db: &'db dyn Db, kind: MarkupKind) -> DisplayHoverContent<'_, 'db> {
fn display<'a>(
&'a self,
db: &'db dyn Db,
kind: MarkupKind,
linkify: Linkify<'a>,
) -> DisplayHoverContent<'a, 'db> {
DisplayHoverContent {
db,
content: self,
kind,
linkify,
}
}
}
@@ -135,6 +150,7 @@ pub(crate) struct DisplayHoverContent<'a, 'db> {
db: &'db dyn Db,
content: &'a HoverContent<'db>,
kind: MarkupKind,
linkify: Linkify<'a>,
}
impl fmt::Display for DisplayHoverContent<'_, '_> {
@@ -151,21 +167,84 @@ impl fmt::Display for DisplayHoverContent<'_, '_> {
Some(TypeVarVariance::Bivariant) => " (bivariant)",
None => "",
};
self.kind
.fenced_code_block(
format!(
"{}{variance}",
ty.display_with(self.db, DisplaySettings::default().multiline())
),
"python",
)
.fmt(f)
if self.kind == MarkupKind::Markdown {
let details = ty.display(self.db).to_string_parts();
// Ok so the idea here is that we potentially have a random soup of spans here,
// and each byte of the string can have at most one target associate with it.
// Thankfully, they were generally pushed in print order, with the inner smaller types
// appearing before the outer bigger ones.
//
// So we record where we are in the string, and every time we find a type, we
// check if it's further along in the string. If it is, great, we give it the
// span for its range, and then advance where we are.
let mut offset = 0;
for (target, detail) in details.targets.iter().zip(&details.details) {
match detail {
TypeDetail::Type(ty) => {
let start = target.start().to_usize();
let end = target.end().to_usize();
// If we skipped over some bytes, push them with no target
if start > offset {
write!(f, "{}", escape(&details.label[offset..start]))?;
}
// Ok, this is the first type that claimed these bytes, give it the target
if start >= offset {
if let Some(target) =
ty.navigation_targets(self.db).into_iter().next()
&& let Some(uri) = (self.linkify)(&target)
{
write!(
f,
"[{}]({})",
escape(&details.label[start..end]),
uri
)?;
} else {
write!(f, "{}", escape(&details.label[start..end]))?;
}
offset = end;
}
}
TypeDetail::SignatureStart
| TypeDetail::SignatureEnd
| TypeDetail::Parameter(_) => {
// Don't care about these
}
}
}
// "flush" the rest of the label without any target
if offset < details.label.len() {
write!(f, "{}", escape(&details.label[offset..details.label.len()]))?;
}
write!(f, "{}", escape(variance))
} else {
self.kind
.fenced_code_block(
format!(
"{}{variance}",
ty.display_with(self.db, DisplaySettings::default().multiline())
),
"python",
)
.fmt(f)
}
}
HoverContent::Docstring(docstring) => docstring.render(self.kind).fmt(f),
}
}
}
fn escape(x: &str) -> String {
x.replace('[', "\\[")
.replace(']', "\\]")
.replace('(', "\\(")
.replace(')', "\\)")
.replace('`', "\\`")
.replace('#', "\\#")
}
#[cfg(test)]
mod tests {
use crate::tests::{CursorTest, cursor_test};
@@ -3670,9 +3749,9 @@ def function():
write!(
&mut buf,
"{plaintext}{line}{markdown}{line}",
plaintext = hover.display(&self.db, MarkupKind::PlainText),
plaintext = hover.display(&self.db, MarkupKind::PlainText, &|_| None),
line = MarkupKind::PlainText.horizontal_line(),
markdown = hover.display(&self.db, MarkupKind::Markdown),
markdown = hover.display(&self.db, MarkupKind::Markdown, &|_| None),
)
.unwrap();

View File

@@ -152,20 +152,6 @@ The expressions in these string annotations aren't valid expressions in this con
shouldn't panic.
```py
# Regression test for https://github.com/astral-sh/ty/issues/1865
# error: [fstring-type-annotation]
stringified_fstring_with_conditional: "f'{1 if 1 else 1}'"
# error: [fstring-type-annotation]
stringified_fstring_with_boolean_expression: "f'{1 or 2}'"
# error: [fstring-type-annotation]
stringified_fstring_with_generator_expression: "f'{(i for i in range(5))}'"
# error: [fstring-type-annotation]
stringified_fstring_with_list_comprehension: "f'{[i for i in range(5)]}'"
# error: [fstring-type-annotation]
stringified_fstring_with_dict_comprehension: "f'{ {i: i for i in range(5)} }'"
# error: [fstring-type-annotation]
stringified_fstring_with_set_comprehension: "f'{ {i for i in range(5)} }'"
a: "1 or 2"
b: "(x := 1)"
# error: [invalid-type-form]

View File

@@ -522,11 +522,6 @@ impl<'db> SemanticIndex<'db> {
self.scopes_by_node[&node.node_key()]
}
/// Returns the id of the scope that `node` creates, if it exists.
pub(crate) fn try_node_scope(&self, node: NodeWithScopeRef) -> Option<FileScopeId> {
self.scopes_by_node.get(&node.node_key()).copied()
}
/// Checks if there is an import of `__future__.annotations` in the global scope, which affects
/// the logic for type inference.
pub(super) fn has_future_annotations(&self) -> bool {

View File

@@ -689,10 +689,20 @@ impl<'db> IntersectionBuilder<'db> {
}
}
pub(crate) fn order_elements(mut self, val: bool) -> Self {
self.order_elements = val;
self
}
pub(crate) fn add_positive(self, ty: Type<'db>) -> Self {
self.add_positive_impl(ty, &mut vec![])
}
pub(crate) fn add_positive_in_place(&mut self, ty: Type<'db>) {
let updated = std::mem::replace(self, Self::empty(self.db)).add_positive(ty);
*self = updated;
}
pub(crate) fn add_positive_impl(
mut self,
ty: Type<'db>,

View File

@@ -1566,9 +1566,6 @@ impl<'db> ClassLiteral<'db> {
})
}
#[salsa::tracked(cycle_initial=generic_context_cycle_initial,
heap_size=ruff_memory_usage::heap_size,
)]
pub(crate) fn legacy_generic_context(self, db: &'db dyn Db) -> Option<GenericContext<'db>> {
self.explicit_bases(db).iter().find_map(|base| match base {
Type::KnownInstance(

File diff suppressed because it is too large Load Diff

View File

@@ -7926,7 +7926,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
let Some(first_comprehension) = comprehensions_iter.next() else {
unreachable!("Comprehension must contain at least one generator");
};
self.infer_maybe_standalone_expression(&first_comprehension.iter, TypeContext::default());
self.infer_standalone_expression(&first_comprehension.iter, TypeContext::default());
if first_comprehension.is_async {
EvaluationMode::Async
@@ -7946,12 +7946,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
let evaluation_mode = self.infer_first_comprehension_iter(generators);
let Some(scope_id) = self
let scope_id = self
.index
.try_node_scope(NodeWithScopeRef::GeneratorExpression(generator))
else {
return Type::unknown();
};
.node_scope(NodeWithScopeRef::GeneratorExpression(generator));
let scope = scope_id.to_scope_id(self.db(), self.file());
let inference = infer_scope_types(self.db(), scope);
let yield_type = inference.expression_type(elt.as_ref());
@@ -8024,12 +8021,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
self.infer_first_comprehension_iter(generators);
let Some(scope_id) = self
let scope_id = self
.index
.try_node_scope(NodeWithScopeRef::ListComprehension(listcomp))
else {
return Type::unknown();
};
.node_scope(NodeWithScopeRef::ListComprehension(listcomp));
let scope = scope_id.to_scope_id(self.db(), self.file());
let inference = infer_scope_types(self.db(), scope);
let element_type = inference.expression_type(elt.as_ref());
@@ -8052,12 +8046,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
self.infer_first_comprehension_iter(generators);
let Some(scope_id) = self
let scope_id = self
.index
.try_node_scope(NodeWithScopeRef::DictComprehension(dictcomp))
else {
return Type::unknown();
};
.node_scope(NodeWithScopeRef::DictComprehension(dictcomp));
let scope = scope_id.to_scope_id(self.db(), self.file());
let inference = infer_scope_types(self.db(), scope);
let key_type = inference.expression_type(key.as_ref());
@@ -8080,12 +8071,9 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
self.infer_first_comprehension_iter(generators);
let Some(scope_id) = self
let scope_id = self
.index
.try_node_scope(NodeWithScopeRef::SetComprehension(setcomp))
else {
return Type::unknown();
};
.node_scope(NodeWithScopeRef::SetComprehension(setcomp));
let scope = scope_id.to_scope_id(self.db(), self.file());
let inference = infer_scope_types(self.db(), scope);
let element_type = inference.expression_type(elt.as_ref());
@@ -8177,14 +8165,14 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
builder.module(),
)
} else {
builder.infer_maybe_standalone_expression(iter, tcx)
builder.infer_standalone_expression(iter, tcx)
}
.iterate(builder.db())
.homogeneous_element_type(builder.db())
});
for expr in ifs {
self.infer_maybe_standalone_expression(expr, TypeContext::default());
self.infer_standalone_expression(expr, TypeContext::default());
}
}
@@ -8290,7 +8278,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
orelse,
} = if_expression;
let test_ty = self.infer_maybe_standalone_expression(test, TypeContext::default());
let test_ty = self.infer_standalone_expression(test, TypeContext::default());
let body_ty = self.infer_expression(body, tcx);
let orelse_ty = self.infer_expression(orelse, tcx);
@@ -10353,7 +10341,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
let ty = if index == values.len() - 1 {
builder.infer_expression(value, TypeContext::default())
} else {
builder.infer_maybe_standalone_expression(value, TypeContext::default())
builder.infer_standalone_expression(value, TypeContext::default())
};
(ty, value.range())

View File

@@ -1,6 +1,6 @@
use std::borrow::Cow;
use crate::document::{FileRangeExt, PositionExt};
use crate::document::{FileRangeExt, PositionExt, ToRangeExt};
use crate::server::api::traits::{
BackgroundDocumentRequestHandler, RequestHandler, RetriableRequestHandler,
};
@@ -8,7 +8,7 @@ use crate::session::DocumentSnapshot;
use crate::session::client::Client;
use lsp_types::request::HoverRequest;
use lsp_types::{HoverContents, HoverParams, MarkupContent, Url};
use ty_ide::{MarkupKind, hover};
use ty_ide::{MarkupKind, NavigationTarget, hover};
use ty_project::ProjectDatabase;
pub(crate) struct HoverRequestHandler;
@@ -61,7 +61,23 @@ impl BackgroundDocumentRequestHandler for HoverRequestHandler {
(MarkupKind::PlainText, lsp_types::MarkupKind::PlainText)
};
let contents = range_info.display(db, markup_kind).to_string();
let to_lsp_link = |target: &NavigationTarget| -> Option<String> {
let file = target.file();
let location = target
.focus_range()
.to_lsp_range(db, file, snapshot.encoding())?
.to_location()?;
let uri = location.uri;
let line1 = location.range.start.line;
let char1 = location.range.start.character;
let line2 = location.range.end.line;
let char2 = location.range.end.character;
Some(format!("{uri}#L{line1}C{char1}-L{line2}C{char2}"))
};
let contents = range_info
.display(db, markup_kind, &to_lsp_link)
.to_string();
Ok(Some(lsp_types::Hover {
contents: HoverContents::Markup(MarkupContent {

View File

@@ -399,7 +399,7 @@ impl Workspace {
Ok(Some(Hover {
markdown: range_info
.display(&self.db, MarkupKind::Markdown)
.display(&self.db, MarkupKind::Markdown, &|_| None)
.to_string(),
range: source_range,
}))