Skip to content

Conversation

@Gankra
Copy link
Contributor

@Gankra Gankra commented Aug 12, 2025

This PR has several components:

  • Introduce a Docstring String wrapper type that has render_plaintext and render_markdown methods, to force docstring handlers to pick a rendering format
    • Implement PEP-257 docstring trimming for it
    • The markdown rendering just renders the content in a plaintext codeblock for now (followup work)
  • Introduce a DefinitionsOrTargets type representing the partial evaluation of GotoTarget::get_definition_targets to ideally stop at getting ResolvedDefinitions
    • Add declaration_targets, definition_targets, and docstring methods to DefinitionsOrTargets for the 3 usecases we have for this operation
    • docstring is of course the key addition here, it uses the same basic logic that signature_help was using: first check the goto-declaration for docstrings, then check the goto-definition for docstrings.
  • Refactor signature_help to use the new APIs instead of implementing it itself
    • Not fixed in this PR: an issue I found where signature_help will erroneously cache docs between functions that have the same type (hover docs don't have this bug)
  • A handful of new tests and additions to tests to add docstrings in various places and see which get caught

Examples of it working with stdlib, third party, and local definitions:
Screenshot 2025-08-12 at 2 13 55 PM
Screenshot 2025-08-12 at 2 14 06 PM
Screenshot 2025-08-12 at 2 14 18 PM

Notably modules don't work yet (followup work):
Screenshot 2025-08-12 at 2 14 37 PM

Notably we don't show docs for an item if you hover its actual definition (followup work, but also, not the most important):
Screenshot 2025-08-12 at 2 16 54 PM

@Gankra Gankra added server Related to the LSP server ty Multi-file analysis & type inference labels Aug 12, 2025
@github-actions
Copy link
Contributor

github-actions bot commented Aug 12, 2025

Diagnostic diff on typing conformance tests

Changes were detected when running ty on typing conformance tests
--- old-output.txt	2025-08-13 14:57:30.986477337 +0000
+++ new-output.txt	2025-08-13 14:57:31.053477542 +0000
@@ -1,5 +1,5 @@
 WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
-fatal[panic] Panicked at /home/runner/.cargo/git/checkouts/salsa-e6f3bb7c2a062968/918d35d/src/function/execute.rs:215:25 when checking `/home/runner/work/ruff/ruff/typing/conformance/tests/aliases_typealiastype.py`: `infer_definition_types(Id(b5023)): execute: too many cycle iterations`
+fatal[panic] Panicked at /home/runner/.cargo/git/checkouts/salsa-e6f3bb7c2a062968/918d35d/src/function/execute.rs:215:25 when checking `/home/runner/work/ruff/ruff/typing/conformance/tests/aliases_typealiastype.py`: `infer_definition_types(Id(244b3)): execute: too many cycle iterations`
 _directives_deprecated_library.py:15:31: error[invalid-return-type] Function always implicitly returns `None`, which is not assignable to return type `int`
 _directives_deprecated_library.py:30:26: error[invalid-return-type] Function always implicitly returns `None`, which is not assignable to return type `str`
 _directives_deprecated_library.py:36:41: error[invalid-return-type] Function always implicitly returns `None`, which is not assignable to return type `Self@__add__`

@github-actions
Copy link
Contributor

github-actions bot commented Aug 12, 2025

mypy_primer results

No ecosystem changes detected ✅
No memory usage changes detected ✅

Comment on lines +332 to +339
x = MyCla<CURSOR>ss(0)
"#,
);

assert_snapshot!(test.hover(), @r"
<class 'MyClass'>
---------------------------------------------
This is such a great class!!
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This result is a disappointment but a pre-existing issue that we don't really distinguish a class from an invocation of the initializer (future work).

Comment on lines 538 to 548
r#"
def test(a: int): ...
def test(ab: int):
"""my cool test
test(a<CURSOR>= 123)
Args:
ab: a nice little integer
"""
return 0
test(a<CURSOR>b= 123)
"#,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This doesn't change the result at all but I changed this test in case one day our hover gets smart enough to see and render these docs.

Comment on lines 577 to 587
def foo(a, b): ...
def foo(a, b):
"""The foo function"""
return 0
def bar(a, b): ...
def bar(a, b):
"""The bar function"""
return 1
if random.choice([True, False]):
a = foo
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Similarly this doesn't change anything, but I added docs just in case something got smarter

Comment on lines 624 to +631

test.write_file("lib.py", "a = 10").unwrap();
test.write_file(
"lib.py",
r"
'''
The cool lib_py module!
Wow this module rocks.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Future work, this should render eventually.

Comment on lines +661 to +673
let mut test = cursor_test(
r#"
import li<CURSOR>b
lib
"#,
);

test.write_file(
"lib.py",
r"
'''
The cool lib_py module!
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This one's even worse and produces no result because apparently hovering an import doesn't yield the module (even though I fixed goto-def on it in a previous PR). I assume this is because hover currently insists it needs the type of the hovered item, not the def/decl.

Comment on lines +977 to 984
'''
My cool func
Args:
a: hopefully a string, right?!
'''
if a is not None:
print(a<CURSOR>)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Another "maybe one day" change

Copy link
Member

@MichaReiser MichaReiser left a comment

Choose a reason for hiding this comment

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

This is excellent. Thank you for working on this. I only have a few detail comments that are at your own discretion.

// NumPy-style docstrings
param_docs.extend(extract_numpy_style_params(docstring));
// Google-style docstrings
param_docs.extend(extract_google_style_params(&self.0));
Copy link
Member

Choose a reason for hiding this comment

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

I know this is pre-existing code, so I'm fine deferring it but I wonder if it makes sense to short-circuit if we parsed parameters by at least one style because most code will only use one convention.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We actually have a test checked in for the mixed behaviour, so i guess @UnboundVariable thought it was something that we'll see in the wild?

/// Normalizes tabs and trims a docstring as specified in PEP-0257
///
/// See: <https://peps.python.org/pep-0257/#handling-docstring-indentation>
fn documentation_trim(docs: &str) -> String {
Copy link
Member

Choose a reason for hiding this comment

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

Yay, our second implementation of this algorithm (it makes sense for them to be different):

/// Format a docstring by trimming whitespace and adjusting the indentation.
///
/// Summary of changes we make:
/// * Normalize the string like all other strings
/// * Ignore docstring that have an escaped newline
/// * Trim all trailing whitespace, except for a chaperone space that avoids quotes or backslashes
/// in the last line.
/// * Trim leading whitespace on the first line, again except for a chaperone space
/// * If there is only content in the first line and after that only whitespace, collapse the
/// docstring into one line
/// * Adjust the indentation (see below)
///
/// # Docstring indentation
///
/// Unlike any other string, like black we change the indentation of docstring lines.
///
/// We want to preserve the indentation inside the docstring relative to the suite statement/block
/// indent that the docstring statement is in, but also want to apply the change of the outer
/// indentation in the docstring, e.g.
/// ```python
/// def sparkle_sky():
/// """Make a pretty sparkly sky.
/// * * ✨ *. .
/// * * ✨ .
/// . * . ✨ * . .
/// """
/// ```
/// should become
/// ```python
/// def sparkle_sky():
/// """Make a pretty sparkly sky.
/// * * ✨ *. .
/// * * ✨ .
/// . * . ✨ * . .
/// """
/// ```
/// We can't compute the full indentation here since we don't know what the block indent of
/// the doc comment will be yet and which we can only have added by formatting each line
/// separately with a hard line break. This means we need to strip shared indentation from
/// docstring while preserving the in-docstring bigger-than-suite-statement indentation. Example:
/// ```python
/// def f():
/// """first line
/// line a
/// line b
/// """
/// ```
/// The docstring indentation is 2, the block indents will change this to 4 (but we can't
/// determine this at this point). The indentation of line a is 2, so we trim ` line a`
/// to `line a`. For line b it's 5, so we trim it to `line b` and pad with 5-2=3 spaces to
/// ` line b`. The closing quotes, being on their own line, are stripped get only the
/// default indentation. Fully formatted:
/// ```python
/// def f():
/// """first line
/// line a
/// line b
/// """
/// ```
///
/// Tabs are counted by padding them to the next multiple of 8 according to
/// [`str.expandtabs`](https://docs.python.org/3/library/stdtypes.html#str.expandtabs).
///
/// Additionally, if any line in the docstring has less indentation than the docstring
/// (effectively a negative indentation wrt. to the current level), we pad all lines to the
/// level of the docstring with spaces.
/// ```python
/// def f():
/// """first line
/// line a
/// line b
/// line c
/// """
/// ```
/// Here line a is 3 columns negatively indented, so we pad all lines by an extra 3 spaces:
/// ```python
/// def f():
/// """first line
/// line a
/// line b
/// line c
/// """
/// ```
/// The indentation is rewritten to all-spaces when using [`IndentStyle::Space`].
/// The formatter preserves tab-indentations when using [`IndentStyle::Tab`], but doesn't convert
/// `indent-width * spaces` to tabs because doing so could break ASCII art and other docstrings
/// that use spaces for alignment.
pub(crate) fn format(normalized: &NormalizedString, f: &mut PyFormatter) -> FormatResult<()> {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In retrospect I really should have checked because of course we do 😓

) -> Option<crate::NavigationTargets> {
match self {
DefinitionsOrTargets::Definitions(definitions) => {
definitions_to_navigation_targets(db, Some(&StubMapper::new(db)), definitions)
Copy link
Member

Choose a reason for hiding this comment

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

Nit: The stub mapper type still feels a bit overkill to me. It feels like it could easily be replaced by an enum with two variants (map, don't map) and a method (that takes db as an argument) that does the mapping.

Maybe something for a follow up PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree, I have several followup refactors planned here.

@Gankra Gankra enabled auto-merge (squash) August 13, 2025 14:57
@Gankra Gankra merged commit d59282e into main Aug 13, 2025
37 checks passed
@Gankra Gankra deleted the gankra/dochover branch August 13, 2025 14:59
dcreager added a commit that referenced this pull request Aug 13, 2025
* main:
  [ty] Add precise inference for indexing, slicing and unpacking `NamedTuple` instances (#19560)
  Add rule code to GitLab description (#19896)
  [ty] render docstrings in hover (#19882)
  [ty] simplify return type of place_from_declarations (#19884)
  [ty] Various minor cleanups to tuple internals (#19891)
  [ty] Improve `sys.version_info` special casing (#19894)
dcreager added a commit that referenced this pull request Aug 13, 2025
…aints

* dcreager/inferrable: (65 commits)
  this was right after all
  mark typevars inferrable as we go
  fix tests
  fix inference of constrained typevars
  [ty] Add precise inference for indexing, slicing and unpacking `NamedTuple` instances (#19560)
  Add rule code to GitLab description (#19896)
  [ty] render docstrings in hover (#19882)
  [ty] simplify return type of place_from_declarations (#19884)
  [ty] Various minor cleanups to tuple internals (#19891)
  [ty] Improve `sys.version_info` special casing (#19894)
  Don't cache files with diagnostics (#19869)
  [ty] support recursive type aliases (#19805)
  [ty] Remove unsafe `salsa::Update` implementations in `tuple.rs` (#19880)
  [ty] Function argument inlay hints (#19269)
  [ty] Remove Salsa interning for `TypedDictType` (#19879)
  Fix `lint.future-annotations` link (#19876)
  [ty] Reduce memory usage of `TupleSpec` and `TupleType` (#19872)
  [ty] Track heap usage of salsa structs (#19790)
  Update salsa to pull in tracked struct changes (#19843)
  [ty] simplify CycleDetector::visit signature (#19873)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

server Related to the LSP server ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants