Compare commits

...

40 Commits

Author SHA1 Message Date
Douglas Creager
eb95e49f96 clean up some claude-isms 2025-10-09 10:06:42 -04:00
Douglas Creager
0817a367e1 spelling 2025-10-09 09:57:53 -04:00
Douglas Creager
34b463d0a1 reword some docs 2025-10-09 09:55:51 -04:00
Douglas Creager
b438631de2 clean up the diff 2025-10-09 08:22:07 -04:00
Douglas Creager
fbc211f344 update tests 2025-10-09 08:20:12 -04:00
Douglas Creager
1810b7c7a3 precommit 2025-10-09 08:09:50 -04:00
Douglas Creager
39353da108 remove finished plan 2025-10-09 08:09:07 -04:00
Douglas Creager
7b88440fc2 update tests 2025-10-09 08:08:57 -04:00
Douglas Creager
8426cc6915 use ty_ide 2025-10-08 16:09:42 -04:00
Douglas Creager
c08a2d6d65 [ty] Fix hover to prefer expression nodes over identifiers
When hovering on an attribute name like 'value' in 'instance.value',
the minimal covering node is the Identifier, but we need the Attribute
expression to get the type. Update find_covering_node to track both
the minimal node and minimal expression, preferring the expression.

This fixes hover on attribute accesses. All hover tests now pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 15:54:27 -04:00
Douglas Creager
6ddf729864 clean up mdtest 2025-10-08 15:51:12 -04:00
Douglas Creager
eaba8bc61e [ty] Add comprehensive hover.md mdtest (partial)
Add hover.md with examples of hover assertions across different
expression types. Some tests still need arrow alignment fixes, but
many sections are passing including basic literals, function definitions,
comprehensions, and the simple hover test file.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 15:25:10 -04:00
Douglas Creager
b18d213869 [ty] Update PLAN.md with testing progress 2025-10-08 15:08:59 -04:00
Douglas Creager
847e5f0c68 [ty] Handle expression statements in hover type inference
When hovering, if we find a statement node (like StmtExpr), extract the
expression from within it. This allows hover assertions to work on
standalone expressions like variable references.

Also add a simple working mdtest to demonstrate hover assertions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 15:08:23 -04:00
Douglas Creager
41772466d5 [ty_test] Store CheckOutput references in SortedCheckOutputs
Update SortedCheckOutputs to store references to CheckOutput instead
of owned values, matching the design of the previous SortedDiagnostics
implementation. This avoids unnecessary cloning and makes the API more
consistent.

Changes:
- SortedCheckOutputs now stores Vec<&CheckOutput>
- new() takes IntoIterator<Item = &CheckOutput>
- LineCheckOutputs.outputs is now &[&CheckOutput]
- Implement Unmatched and UnmatchedWithColumn for &CheckOutput
- Update match_line to take &[&CheckOutput]

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 14:57:33 -04:00
Douglas Creager
180d9de472 [ty_test] Fix clippy warnings in hover module
- Elide unnecessary explicit lifetimes in find_covering_node
- Add backticks around CheckOutputs in doc comment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 14:48:55 -04:00
Douglas Creager
adf58b6c19 [ty_test] Change match_line to take slice of CheckOutput
Update match_line signature to accept &[CheckOutput] instead of
&LineCheckOutputs for consistency with the previous API that took
&[Diagnostic].

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 14:47:45 -04:00
Douglas Creager
b683da8cde [ty_test] Store hover column as OneIndexed
Store the hover assertion column as OneIndexed since that's the type
returned by line_column() and required by SourceLocation. This
eliminates unnecessary conversions between zero-based and one-based
indexing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 14:41:34 -04:00
Douglas Creager
6d1c549c4e [ty_test] Simplify column calculation using line_column
Instead of manually calculating character offsets, use line_column()
which does all the work for us:

1. Find the byte offset of the arrow in the comment text
2. Add that to the comment's TextRange to get the arrow's absolute position
3. Call line_column() on that position to get the character column

This is much simpler and lets line_column handle all the UTF-8/UTF-32
conversion complexity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 14:36:24 -04:00
Douglas Creager
c574dff6b0 [ty_test] Fix column units: use character offset not byte offset
The column field was being treated inconsistently - calculated as a
character offset but used as a byte offset. This would break on any
line with multi-byte UTF-8 characters before the hover position.

Fixes:
1. Use chars().position() instead of find() to get character offset
2. Use LineIndex::offset() with PositionEncoding::Utf32 to properly
   convert character offset to byte offset (TextSize)
3. Document that column is a UTF-32 character offset

This ensures hover assertions work correctly with Unicode text.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 14:33:29 -04:00
Douglas Creager
8c12fcb927 [ty_test] Use named fields for UnparsedAssertion::Hover
Convert the Hover variant from tuple-style to named fields for better
clarity and self-documentation.

Before: Hover(&'a str, &'a str, TextRange)
After: Hover { expected_type, full_comment, range }

This makes the code more readable and easier to maintain.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 14:26:28 -04:00
Douglas Creager
a2eaf7ce26 [ty_test] Calculate hover column at parse time
Move the column calculation from generate_hover_outputs into
HoverAssertion::from_str() by passing LineIndex and SourceText to
the parse() method.

This simplifies the code and centralizes the column calculation logic
in one place during parsing, rather than spreading it across multiple
locations. The HoverAssertion now directly stores the final column
position in the line, ready to use.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 14:23:07 -04:00
Douglas Creager
c3626a6d74 [ty_test] Fix hover column calculation to use line position
The previous implementation calculated the down arrow position within
the comment text, but then incorrectly used that as an offset into the
target line. This only worked if the comment started at column 0.

Now we properly calculate the column position by:
1. Finding the arrow offset within the comment text
2. Getting the comment's column position in its line
3. Adding these together to get the arrow's column in the line
4. Using that column to index into the target line

This fixes hover assertions when comments are indented or don't start
at the beginning of the line.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 14:21:03 -04:00
Douglas Creager
8221450cbc [ty_test] Store HoverAssertion.column as zero-based
Change the column field in HoverAssertion from OneIndexed to usize,
storing it as a zero-based index. This matches how it's used and
eliminates unnecessary conversions between zero-based and one-based
indexing.

The arrow position from find() is already zero-based, and TextSize
uses zero-based offsets, so this is more natural.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 14:18:03 -04:00
Douglas Creager
93db8833ef [ty_test] Add use statements to hover.rs
Import InlineFileAssertions, ParsedAssertion, and UnparsedAssertion
at the module level instead of using fully qualified crate::assertion
names throughout the code.

This makes the code cleaner and more idiomatic.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 14:16:16 -04:00
Douglas Creager
51d5bc709d [ty_test] Calculate hover column at parse time
Move the column calculation from generate_hover_outputs to the
HoverAssertion parsing logic. This makes better use of the existing
column field in HoverAssertion.

Changes:
- UnparsedAssertion::Hover now stores both the expected type and the
  full comment text
- HoverAssertion::from_str() now takes both parameters and calculates
  the column from the down arrow position in the full comment
- generate_hover_outputs() now reads the column from the parsed
  assertion instead of recalculating it

This eliminates redundant calculations and makes the column field
actually useful.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 14:09:00 -04:00
Douglas Creager
0198857224 [ty_test] Simplify unmatched output handling
Implement UnmatchedWithColumn for CheckOutput and update the column()
method to work with CheckOutput instead of just Diagnostic.

This allows eliminating the match statement when formatting unmatched
outputs - we can now just call unmatched_with_column() on all outputs
uniformly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 13:57:58 -04:00
Douglas Creager
35b568dd6b [ty_test] Move HoverOutput to hover module
Move the HoverOutput type from check_output.rs to hover.rs where it
logically belongs. The check_output module should only contain the
CheckOutput enum and sorting infrastructure, while hover-specific
types belong in the hover module.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 13:54:59 -04:00
Douglas Creager
35a5fd767d [ty_test] Extract HoverOutput type from CheckOutput enum
Create a dedicated HoverOutput struct to hold hover result data,
replacing the inline fields in CheckOutput::Hover variant.

This allows implementing Unmatched and UnmatchedWithColumn traits
directly on HoverOutput, simplifying the CheckOutput implementations
to simple delegation.

Benefits:
- Better separation of concerns
- Cleaner trait implementations
- More consistent with Diagnostic handling
- Easier to extend HoverOutput in the future

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 13:52:58 -04:00
Douglas Creager
ac1b68c56d [ty_test] Refactor: rename diagnostic.rs to check_output.rs
SortedDiagnostics is now replaced by SortedCheckOutputs, which handles
both diagnostics and hover results. This refactoring:

- Renames diagnostic.rs to check_output.rs to better reflect its purpose
- Moves CheckOutput and SortedCheckOutputs definitions from matcher.rs
  to check_output.rs where they belong
- Removes the now-unused SortedDiagnostics infrastructure
- Ports the test to use SortedCheckOutputs instead of SortedDiagnostics
- Updates all imports throughout the codebase

The check_output module now serves as the central location for sorting
and grouping all types of check outputs (diagnostics and hover results)
by line number.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 13:20:18 -04:00
Douglas Creager
ab261360e4 [ty_test] Use let-else pattern in generate_hover_outputs
Replace nested if-let blocks with let-else + continue to reduce
indentation and improve readability.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 13:09:29 -04:00
Douglas Creager
19600ecd51 [ty_test] Fix hover assertion line number calculation
The previous implementation incorrectly assumed the target line was
always line_number + 1, which breaks when multiple assertion comments
are stacked on consecutive lines.

Now generate_hover_outputs accepts the parsed InlineFileAssertions,
which already correctly associates each assertion with its target line
number (accounting for stacked comments).

For the column position, we extract it directly from the down arrow
position in the UnparsedAssertion::Hover text, avoiding the need to
parse the assertion ourselves (parsing happens later in the matcher).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 13:05:07 -04:00
Douglas Creager
319f5be78c [ty_test] Simplify find_covering_node by comparing range lengths
Replace the leave_node() approach with a simpler strategy: just compare
the range lengths and keep the smallest node that covers the offset.

This is more direct and easier to understand than tracking when we
leave nodes. The minimal covering node is simply the one with the
shortest range that contains the offset.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 12:54:42 -04:00
Douglas Creager
6370aea644 [ty_test] Fix find_covering_node to correctly find minimal node
The previous implementation had a bug: it would overwrite `found` for
every matching node in source order, which could incorrectly select a
sibling node instead of the minimal covering node.

Now use the same approach as ty_ide's covering_node:
- Use leave_node() to detect when we've finished traversing a subtree
- Set a `found` flag when leaving the minimal node to prevent further
  updates
- This ensures we return the deepest (most specific) node that covers
  the offset, not just the last one visited in source order

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 12:53:06 -04:00
Douglas Creager
9d8e35b165 [ty_test] Simplify infer_type_at_position using as_expr_ref()
Replace the large match statement over all AnyNodeRef expression variants
with a simple call to as_expr_ref(). This helper method handles all
expression-related variants automatically, reducing the function from
~60 lines to just 6 lines.

Also removes statement-related variants (StmtFunctionDef, StmtClassDef,
StmtExpr) to focus only on expression nodes, which is the primary use
case for hover assertions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 12:50:28 -04:00
Douglas Creager
5c75e91abe [ty_test] Refactor hover logic into separate module
Move hover-related functions from lib.rs into a new hover.rs module
to improve code organization:
- find_covering_node() - locate AST nodes at specific offsets
- infer_type_at_position() - get inferred types using SemanticModel
- generate_hover_outputs() - scan for hover assertions and generate results

This keeps lib.rs focused on the main test execution flow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 12:47:15 -04:00
Douglas Creager
6e73b859ef Update PLAN.md - mark steps 4 & 5 complete
All core hover assertion functionality is now implemented and compiling!

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 12:41:25 -04:00
Douglas Creager
11e5ecb91e Implement hover type inference and matching for mdtest
- Add find_covering_node() to locate AST nodes at specific positions
- Add infer_type_at_position() to get type information using ty_python_semantic
- Add generate_hover_outputs() to scan for hover assertions and generate results
- Integrate hover outputs into check flow in run_test()
- Implement hover matching logic in matcher.rs to compare types
- Avoid adding ty_ide dependency; instead use ty_python_semantic directly

The implementation extracts hover assertions from comments, computes the target
position from the down arrow location, infers the type at that position using
the AST and SemanticModel, and matches it against the expected type in the
assertion.

Hover assertions now work end-to-end in mdtest files!

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 12:40:52 -04:00
Douglas Creager
be47fbe0f6 Add CheckOutput enum to support hover results in mdtest
- Create CheckOutput enum with Diagnostic and Hover variants
- Replace SortedDiagnostics with SortedCheckOutputs in matcher
- Update match_file to accept &[CheckOutput] instead of &[Diagnostic]
- Update matching logic to handle both diagnostics and hover results
- Implement Unmatched trait for CheckOutput
- Convert diagnostics to CheckOutput in lib.rs before matching

This infrastructure allows hover assertions to be matched against hover
results without polluting the DiagnosticId enum with test-specific IDs.
The hover matching logic will be implemented in the next commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 12:28:09 -04:00
Douglas Creager
37effea8fd Add hover assertion type to mdtest framework
- Add Hover variant to UnparsedAssertion and ParsedAssertion enums
- Create HoverAssertion struct with column and expected_type fields
- Add HoverAssertionParseError enum for validation errors
- Update from_comment() to recognize '# hover:' and '# ↓ hover:' patterns
- Simplified design: down arrow must appear immediately before 'hover' keyword
- Add placeholder matching logic in matcher.rs (to be completed)
- Add PLAN.md to track implementation progress

The hover assertion syntax uses a down arrow to indicate column position:
    # ↓ hover: expected_type
    expression_to_hover

This will enable testing hover functionality in mdtest files, similar to
how ty_ide tests work with <CURSOR> markers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 12:19:21 -04:00
11 changed files with 846 additions and 266 deletions

1
Cargo.lock generated
View File

@@ -4460,6 +4460,7 @@ dependencies = [
"thiserror 2.0.16",
"toml",
"tracing",
"ty_ide",
"ty_python_semantic",
"ty_static",
"ty_vendored",

View File

@@ -22,7 +22,7 @@ use ty_python_semantic::{
};
#[derive(Clone, Debug)]
pub(crate) enum GotoTarget<'a> {
pub enum GotoTarget<'a> {
Expression(ast::ExprRef<'a>),
FunctionDef(&'a ast::StmtFunctionDef),
ClassDef(&'a ast::StmtClassDef),
@@ -269,7 +269,7 @@ impl<'db> DefinitionsOrTargets<'db> {
}
impl GotoTarget<'_> {
pub(crate) fn inferred_type<'db>(&self, model: &SemanticModel<'db>) -> Option<Type<'db>> {
pub fn inferred_type<'db>(&self, model: &SemanticModel<'db>) -> Option<Type<'db>> {
let ty = match self {
GotoTarget::Expression(expression) => expression.inferred_type(model),
GotoTarget::FunctionDef(function) => function.inferred_type(model),
@@ -820,10 +820,7 @@ fn definitions_to_navigation_targets<'db>(
}
}
pub(crate) fn find_goto_target(
parsed: &ParsedModuleRef,
offset: TextSize,
) -> Option<GotoTarget<'_>> {
pub fn find_goto_target(parsed: &ParsedModuleRef, offset: TextSize) -> Option<GotoTarget<'_>> {
let token = parsed
.tokens()
.at_offset(offset)

View File

@@ -30,7 +30,7 @@ pub use all_symbols::{AllSymbolInfo, all_symbols};
pub use completion::{Completion, CompletionKind, CompletionSettings, completion};
pub use doc_highlights::document_highlights;
pub use document_symbols::document_symbols;
pub use goto::{goto_declaration, goto_definition, goto_type_definition};
pub use goto::{find_goto_target, goto_declaration, goto_definition, goto_type_definition};
pub use goto_references::goto_references;
pub use hover::hover;
pub use inlay_hints::{InlayHintKind, InlayHintLabel, InlayHintSettings, inlay_hints};

View File

@@ -0,0 +1,140 @@
# Hover type assertions
You can use the `hover` assertion to test the inferred type of an expression. This exercises the
same logic as the hover LSP action.
Typically, you will not need to use the `hover` action to test the behavior of our type inference
code, since you can also use `reveal_type` to display the inferred type of an expression. Since
`reveal_type` is part of the standard library, we prefer to use it when possible.
However, there are certain situations where `reveal_type` and `hover` will give different results.
In particular, `reveal_type` is not transparent to bidirectional type checking, as seen in the
"Different results" section below.
## Syntax
### Basic syntax
The `hover` assertion operates on a specific location in the source text. We find the "inner-most"
expression at that position, and then query the inferred type of that expression. The row to query
is identified just like any other mdtest assertion. The column to query is identified by a down
arrow (↓) in the assertion. (Note that the down arrow should always appear immediately before the
`hover` keyword in the assertion.)
```py
def test_basic_types(parameter: int) -> None:
# ↓ hover: int
parameter
# ↓ hover: Literal[10]
number = 10
# ↓ hover: Literal["hello"]
text = "hello"
```
### Multiple hovers on the same line
We can have multiple hover assertions for different positions on the same line:
```py
# ↓ hover: Literal[1]
# ↓ hover: Literal[2]
# ↓ hover: Literal[3]
total = 1 + 2 + 3
# ↓ hover: Literal[5]
# ↓ hover: Literal[3]
result = max(5, 3)
```
### Hovering works on every character in an expression
```py
def _(param: bool) -> None:
# ↓ hover: bool
# ↓ hover: bool
# ↓ hover: bool
# ↓ hover: bool
# ↓ hover: bool
result = param
```
### Hovering with unicode characters
```py
def _(café: str) -> None:
# ↓ hover: str
# ↓ hover: str
# ↓ hover: str
# ↓ hover: str
result = café
```
## Different results for `reveal_type` and `hover`
```py
from typing import overload
def f(x: dict[str, int]) -> None: ...
# revealed: dict[Unknown, Unknown]
f(reveal_type({}))
# ↓ hover: dict[str, int]
f({})
```
## Hovering on different expression types
### Literals
```py
# ↓ hover: Literal[42]
int_value = 42
# ↓ hover: Literal["test"]
string_value = "test"
# ↓ hover: Literal[True]
bool_value = True
```
### Names and attributes
```py
class MyClass:
value: int
def test_attributes(instance: MyClass) -> None:
# ↓ hover: MyClass
instance
# ↓ hover: int
instance.value
```
### Function definitions
```py
def f(x: int) -> None: ...
# ↓ hover: def f(x: int) -> None
result = f
```
### Binary operations
```py
# ↓ hover: Literal[10]
# ↓ hover: Literal[20]
result = 10 + 20
```
### Comprehensions
```py
# List comprehension
# ↓ hover: list[@Todo(list comprehension element type)]
result = [x for x in range(5)]
```

View File

@@ -19,6 +19,7 @@ ruff_source_file = { workspace = true }
ruff_text_size = { workspace = true }
ruff_python_ast = { workspace = true }
ty_python_semantic = { workspace = true, features = ["serde", "testing"] }
ty_ide = { workspace = true }
ty_static = { workspace = true }
ty_vendored = { workspace = true }

View File

@@ -129,7 +129,7 @@ impl<'a> Iterator for AssertionWithRangeIterator<'a> {
loop {
let inner_next = self.inner.next()?;
let comment = &self.file_assertions.source[inner_next];
if let Some(assertion) = UnparsedAssertion::from_comment(comment) {
if let Some(assertion) = UnparsedAssertion::from_comment(comment, inner_next) {
return Some(AssertionWithRange(assertion, inner_next));
}
}
@@ -245,26 +245,45 @@ pub(crate) enum UnparsedAssertion<'a> {
/// An `# error:` assertion.
Error(&'a str),
/// A `# hover:` assertion.
Hover {
/// The expected type (body after `hover:`).
expected_type: &'a str,
/// The full comment text (including the down arrow).
full_comment: &'a str,
/// The position of the comment in the source file.
range: TextRange,
},
}
impl<'a> UnparsedAssertion<'a> {
/// Returns `Some(_)` if the comment starts with `# error:` or `# revealed:`,
/// Returns `Some(_)` if the comment starts with `# error:`, `# revealed:`, or `# hover:`,
/// indicating that it is an assertion comment.
fn from_comment(comment: &'a str) -> Option<Self> {
let comment = comment.trim().strip_prefix('#')?.trim();
let (keyword, body) = comment.split_once(':')?;
fn from_comment(comment: &'a str, range: TextRange) -> Option<Self> {
let trimmed = comment.trim().strip_prefix('#')?.trim();
let (keyword, body) = trimmed.split_once(':')?;
let keyword = keyword.trim();
let body = body.trim();
match keyword {
"revealed" => Some(Self::Revealed(body)),
"error" => Some(Self::Error(body)),
"↓ hover" => Some(Self::Hover {
expected_type: body,
full_comment: comment,
range,
}),
_ => None,
}
}
/// Parse the attempted assertion into a [`ParsedAssertion`] structured representation.
pub(crate) fn parse(&self) -> Result<ParsedAssertion<'a>, PragmaParseError<'a>> {
pub(crate) fn parse(
&self,
line_index: &ruff_source_file::LineIndex,
source: &ruff_db::source::SourceText,
) -> Result<ParsedAssertion<'a>, PragmaParseError<'a>> {
match self {
Self::Revealed(revealed) => {
if revealed.is_empty() {
@@ -276,6 +295,13 @@ impl<'a> UnparsedAssertion<'a> {
Self::Error(error) => ErrorAssertion::from_str(error)
.map(ParsedAssertion::Error)
.map_err(PragmaParseError::ErrorAssertionParseError),
Self::Hover {
expected_type,
full_comment,
range,
} => HoverAssertion::from_str(expected_type, full_comment, *range, line_index, source)
.map(ParsedAssertion::Hover)
.map_err(PragmaParseError::HoverAssertionParseError),
}
}
}
@@ -285,18 +311,22 @@ impl std::fmt::Display for UnparsedAssertion<'_> {
match self {
Self::Revealed(expected_type) => write!(f, "revealed: {expected_type}"),
Self::Error(assertion) => write!(f, "error: {assertion}"),
Self::Hover { expected_type, .. } => write!(f, "hover: {expected_type}"),
}
}
}
/// An assertion comment that has been parsed and validated for correctness.
#[derive(Debug)]
#[derive(Debug, Eq, PartialEq)]
pub(crate) enum ParsedAssertion<'a> {
/// A `# revealed:` assertion.
Revealed(&'a str),
/// An `# error:` assertion.
Error(ErrorAssertion<'a>),
/// A `# hover:` assertion.
Hover(HoverAssertion<'a>),
}
impl std::fmt::Display for ParsedAssertion<'_> {
@@ -304,12 +334,13 @@ impl std::fmt::Display for ParsedAssertion<'_> {
match self {
Self::Revealed(expected_type) => write!(f, "revealed: {expected_type}"),
Self::Error(assertion) => assertion.fmt(f),
Self::Hover(assertion) => assertion.fmt(f),
}
}
}
/// A parsed and validated `# error:` assertion comment.
#[derive(Debug)]
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct ErrorAssertion<'a> {
/// The diagnostic rule code we expect.
pub(crate) rule: Option<&'a str>,
@@ -343,6 +374,57 @@ impl std::fmt::Display for ErrorAssertion<'_> {
}
}
/// A parsed and validated `# hover:` assertion comment.
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct HoverAssertion<'a> {
/// The one-based character column (UTF-32) in the line where the down arrow appears.
/// This indicates the character position in the target line where we should hover.
pub(crate) column: OneIndexed,
/// The expected type at the hover position.
pub(crate) expected_type: &'a str,
}
impl<'a> HoverAssertion<'a> {
fn from_str(
expected_type: &'a str,
full_comment: &'a str,
comment_range: TextRange,
line_index: &ruff_source_file::LineIndex,
source: &ruff_db::source::SourceText,
) -> Result<Self, HoverAssertionParseError> {
if expected_type.is_empty() {
return Err(HoverAssertionParseError::EmptyType);
}
// Find the down arrow position within the comment text (as byte offset)
let arrow_byte_offset_in_comment = full_comment
.find('↓')
.ok_or(HoverAssertionParseError::MissingDownArrow)?;
// Calculate the TextSize position of the down arrow in the source file
let arrow_position =
comment_range.start() + TextSize::try_from(arrow_byte_offset_in_comment).unwrap();
// Get the line and character column of the down arrow
let arrow_line_col = line_index.line_column(arrow_position, source);
// Store the character column (which line_column already computed for us)
let column = arrow_line_col.column;
Ok(Self {
column,
expected_type,
})
}
}
impl std::fmt::Display for HoverAssertion<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "hover: {}", self.expected_type)
}
}
/// A parser to convert a string into a [`ErrorAssertion`].
#[derive(Debug, Clone)]
struct ErrorAssertionParser<'a> {
@@ -454,17 +536,19 @@ impl<'a> ErrorAssertionParser<'a> {
/// Enumeration of ways in which parsing an assertion comment can fail.
///
/// The assertion comment could be either a "revealed" assertion or an "error" assertion.
#[derive(Debug, thiserror::Error)]
/// The assertion comment could be a "revealed", "error", or "hover" assertion.
#[derive(Debug, Eq, PartialEq, thiserror::Error)]
pub(crate) enum PragmaParseError<'a> {
#[error("Must specify which type should be revealed")]
EmptyRevealTypeAssertion,
#[error("{0}")]
ErrorAssertionParseError(ErrorAssertionParseError<'a>),
#[error("{0}")]
HoverAssertionParseError(HoverAssertionParseError),
}
/// Enumeration of ways in which parsing an *error* assertion comment can fail.
#[derive(Debug, thiserror::Error)]
#[derive(Debug, Eq, PartialEq, thiserror::Error)]
pub(crate) enum ErrorAssertionParseError<'a> {
#[error("no rule or message text")]
NoRuleOrMessage,
@@ -486,6 +570,15 @@ pub(crate) enum ErrorAssertionParseError<'a> {
UnexpectedCharacter { character: char, offset: usize },
}
/// Enumeration of ways in which parsing a *hover* assertion comment can fail.
#[derive(Debug, Eq, PartialEq, thiserror::Error)]
pub(crate) enum HoverAssertionParseError {
#[error("Hover assertion must contain a down arrow (↓) to indicate position")]
MissingDownArrow,
#[error("Must specify which type to expect at hover position")]
EmptyType,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -815,4 +908,171 @@ mod tests {
r#"error: 1 [unbound-name] "`x` is unbound""#
);
}
#[test]
fn hover_basic() {
let assertions = get_assertions(&dedent(
"
# ↓ hover: int
x
",
));
let [line] = &as_vec(&assertions)[..] else {
panic!("expected one line");
};
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2));
let [assert] = &line.assertions[..] else {
panic!("expected one assertion");
};
assert_eq!(format!("{assert}"), "hover: int");
}
#[test]
fn hover_with_spaces_before_arrow() {
let assertions = get_assertions(&dedent(
"
# ↓ hover: str
value
",
));
let [line] = &as_vec(&assertions)[..] else {
panic!("expected one line");
};
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2));
let [assert] = &line.assertions[..] else {
panic!("expected one assertion");
};
assert_eq!(format!("{assert}"), "hover: str");
}
#[test]
fn hover_complex_type() {
let assertions = get_assertions(&dedent(
"
# ↓ hover: list[@Todo(list comprehension element type)]
result
",
));
let [line] = &as_vec(&assertions)[..] else {
panic!("expected one line");
};
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2));
let [assert] = &line.assertions[..] else {
panic!("expected one assertion");
};
assert_eq!(
format!("{assert}"),
"hover: list[@Todo(list comprehension element type)]"
);
}
#[test]
fn hover_multiple_on_same_line() {
let assertions = get_assertions(&dedent(
"
# ↓ hover: Literal[1]
# ↓ hover: Literal[2]
x = 1 + 2
",
));
let [line] = &as_vec(&assertions)[..] else {
panic!("expected one line");
};
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(3));
let [assert1, assert2] = &line.assertions[..] else {
panic!("expected two assertions");
};
assert_eq!(format!("{assert1}"), "hover: Literal[1]");
assert_eq!(format!("{assert2}"), "hover: Literal[2]");
}
#[test]
fn hover_mixed_with_other_assertions() {
let assertions = get_assertions(&dedent(
"
# ↓ hover: int
# error: [some-error]
x
",
));
let [line] = &as_vec(&assertions)[..] else {
panic!("expected one line");
};
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(3));
let [assert1, assert2] = &line.assertions[..] else {
panic!("expected two assertions");
};
assert_eq!(format!("{assert1}"), "hover: int");
assert_eq!(format!("{assert2}"), "error: [some-error]");
}
#[test]
fn hover_parsed_column() {
use ruff_db::files::system_path_to_file;
let mut db = Db::setup();
let settings = ProgramSettings {
python_version: PythonVersionWithSource::default(),
python_platform: PythonPlatform::default(),
search_paths: SearchPathSettings::new(Vec::new())
.to_search_paths(db.system(), db.vendored())
.unwrap(),
};
Program::init_or_update(&mut db, settings);
let source_code = dedent(
"
# ↓ hover: Literal[10]
value = 10
",
);
db.write_file("/src/test.py", &source_code).unwrap();
let file = system_path_to_file(&db, "/src/test.py").unwrap();
let assertions = InlineFileAssertions::from_file(&db, file);
let [line] = &as_vec(&assertions)[..] else {
panic!("expected one line");
};
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2));
let [assert] = &line.assertions[..] else {
panic!("expected one assertion");
};
// Parse the assertion to verify column is extracted correctly
let source = ruff_db::source::source_text(&db, file);
let lines = ruff_db::source::line_index(&db, file);
let parsed = assert.parse(&lines, &source);
assert_eq!(
parsed,
Ok(ParsedAssertion::Hover(HoverAssertion {
column: OneIndexed::from_zero_indexed(7),
expected_type: "Literal[10]"
}))
);
}
}

View File

@@ -0,0 +1,204 @@
//! Sort and group check outputs (diagnostics and hover results) by line number,
//! so they can be correlated with assertions.
//!
//! We don't assume that we will get the outputs in source order.
use ruff_db::diagnostic::Diagnostic;
use ruff_source_file::{LineIndex, OneIndexed};
use std::ops::Range;
use crate::hover::HoverOutput;
/// Represents either a diagnostic or a hover result for matching against assertions.
#[derive(Debug, Clone)]
pub(crate) enum CheckOutput {
/// A regular diagnostic from the type checker
Diagnostic(Diagnostic),
/// A hover result for testing hover assertions
Hover(HoverOutput),
}
impl CheckOutput {
fn line_number(&self, line_index: &LineIndex) -> OneIndexed {
match self {
CheckOutput::Diagnostic(diag) => diag
.primary_span()
.and_then(|span| span.range())
.map_or(OneIndexed::from_zero_indexed(0), |range| {
line_index.line_index(range.start())
}),
CheckOutput::Hover(hover) => line_index.line_index(hover.offset),
}
}
}
/// All check outputs for one embedded Python file, sorted and grouped by line number.
///
/// The outputs are kept in a flat vector, sorted by line number. A separate vector of
/// [`LineOutputRange`] has one entry for each contiguous slice of the `outputs` vector
/// containing outputs which all start on the same line.
#[derive(Debug)]
pub(crate) struct SortedCheckOutputs<'a> {
outputs: Vec<&'a CheckOutput>,
line_ranges: Vec<LineOutputRange>,
}
impl<'a> SortedCheckOutputs<'a> {
pub(crate) fn new(
outputs: impl IntoIterator<Item = &'a CheckOutput>,
line_index: &LineIndex,
) -> Self {
let mut outputs: Vec<_> = outputs
.into_iter()
.map(|output| OutputWithLine {
line_number: output.line_number(line_index),
output,
})
.collect();
outputs.sort_unstable_by_key(|output_with_line| output_with_line.line_number);
let mut result = Self {
outputs: Vec::with_capacity(outputs.len()),
line_ranges: vec![],
};
let mut current_line_number = None;
let mut start = 0;
for OutputWithLine {
line_number,
output,
} in outputs
{
match current_line_number {
None => {
current_line_number = Some(line_number);
}
Some(current) => {
if line_number != current {
let end = result.outputs.len();
result.line_ranges.push(LineOutputRange {
line_number: current,
output_index_range: start..end,
});
start = end;
current_line_number = Some(line_number);
}
}
}
result.outputs.push(output);
}
if let Some(line_number) = current_line_number {
result.line_ranges.push(LineOutputRange {
line_number,
output_index_range: start..result.outputs.len(),
});
}
result
}
pub(crate) fn iter_lines(&self) -> LineCheckOutputsIterator<'_> {
LineCheckOutputsIterator {
outputs: self.outputs.as_slice(),
inner: self.line_ranges.iter(),
}
}
}
#[derive(Debug)]
struct OutputWithLine<'a> {
line_number: OneIndexed,
output: &'a CheckOutput,
}
/// Range delineating check outputs in [`SortedCheckOutputs`] that belong to a single line.
#[derive(Debug)]
struct LineOutputRange {
line_number: OneIndexed,
output_index_range: Range<usize>,
}
/// Iterator to group sorted check outputs by line.
pub(crate) struct LineCheckOutputsIterator<'a> {
outputs: &'a [&'a CheckOutput],
inner: std::slice::Iter<'a, LineOutputRange>,
}
impl<'a> Iterator for LineCheckOutputsIterator<'a> {
type Item = LineCheckOutputs<'a>;
fn next(&mut self) -> Option<Self::Item> {
let LineOutputRange {
line_number,
output_index_range,
} = self.inner.next()?;
Some(LineCheckOutputs {
line_number: *line_number,
outputs: &self.outputs[output_index_range.clone()],
})
}
}
impl std::iter::FusedIterator for LineCheckOutputsIterator<'_> {}
/// All check outputs that belong to a single line of source code in one embedded Python file.
#[derive(Debug)]
pub(crate) struct LineCheckOutputs<'a> {
/// Line number on which these outputs start.
pub(crate) line_number: OneIndexed,
/// Check outputs starting on this line.
pub(crate) outputs: &'a [&'a CheckOutput],
}
#[cfg(test)]
mod tests {
use crate::db::Db;
use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, LintName, Severity, Span};
use ruff_db::files::system_path_to_file;
use ruff_db::source::line_index;
use ruff_db::system::DbWithWritableSystem as _;
use ruff_source_file::OneIndexed;
use ruff_text_size::{TextRange, TextSize};
#[test]
fn sort_and_group() {
let mut db = Db::setup();
db.write_file("/src/test.py", "one\ntwo\n").unwrap();
let file = system_path_to_file(&db, "/src/test.py").unwrap();
let lines = line_index(&db, file);
let ranges = [
TextRange::new(TextSize::new(0), TextSize::new(1)),
TextRange::new(TextSize::new(5), TextSize::new(10)),
TextRange::new(TextSize::new(1), TextSize::new(7)),
];
let check_outputs: Vec<_> = ranges
.into_iter()
.map(|range| {
let mut diag = Diagnostic::new(
DiagnosticId::Lint(LintName::of("dummy")),
Severity::Error,
"dummy",
);
let span = Span::from(file).with_range(range);
diag.annotate(Annotation::primary(span));
super::CheckOutput::Diagnostic(diag)
})
.collect();
let sorted = super::SortedCheckOutputs::new(&check_outputs, &lines);
let grouped = sorted.iter_lines().collect::<Vec<_>>();
let [line1, line2] = &grouped[..] else {
panic!("expected two lines");
};
assert_eq!(line1.line_number, OneIndexed::from_zero_indexed(0));
assert_eq!(line1.outputs.len(), 2);
assert_eq!(line2.line_number, OneIndexed::from_zero_indexed(1));
assert_eq!(line2.outputs.len(), 1);
}
}

View File

@@ -1,190 +0,0 @@
//! Sort and group diagnostics by line number, so they can be correlated with assertions.
//!
//! We don't assume that we will get the diagnostics in source order.
use ruff_db::diagnostic::Diagnostic;
use ruff_source_file::{LineIndex, OneIndexed};
use std::ops::{Deref, Range};
/// All diagnostics for one embedded Python file, sorted and grouped by start line number.
///
/// The diagnostics are kept in a flat vector, sorted by line number. A separate vector of
/// [`LineDiagnosticRange`] has one entry for each contiguous slice of the diagnostics vector
/// containing diagnostics which all start on the same line.
#[derive(Debug)]
pub(crate) struct SortedDiagnostics<'a> {
diagnostics: Vec<&'a Diagnostic>,
line_ranges: Vec<LineDiagnosticRange>,
}
impl<'a> SortedDiagnostics<'a> {
pub(crate) fn new(
diagnostics: impl IntoIterator<Item = &'a Diagnostic>,
line_index: &LineIndex,
) -> Self {
let mut diagnostics: Vec<_> = diagnostics
.into_iter()
.map(|diagnostic| DiagnosticWithLine {
line_number: diagnostic
.primary_span()
.and_then(|span| span.range())
.map_or(OneIndexed::from_zero_indexed(0), |range| {
line_index.line_index(range.start())
}),
diagnostic,
})
.collect();
diagnostics.sort_unstable_by_key(|diagnostic_with_line| diagnostic_with_line.line_number);
let mut diags = Self {
diagnostics: Vec::with_capacity(diagnostics.len()),
line_ranges: vec![],
};
let mut current_line_number = None;
let mut start = 0;
for DiagnosticWithLine {
line_number,
diagnostic,
} in diagnostics
{
match current_line_number {
None => {
current_line_number = Some(line_number);
}
Some(current) => {
if line_number != current {
let end = diags.diagnostics.len();
diags.line_ranges.push(LineDiagnosticRange {
line_number: current,
diagnostic_index_range: start..end,
});
start = end;
current_line_number = Some(line_number);
}
}
}
diags.diagnostics.push(diagnostic);
}
if let Some(line_number) = current_line_number {
diags.line_ranges.push(LineDiagnosticRange {
line_number,
diagnostic_index_range: start..diags.diagnostics.len(),
});
}
diags
}
pub(crate) fn iter_lines(&self) -> LineDiagnosticsIterator<'_> {
LineDiagnosticsIterator {
diagnostics: self.diagnostics.as_slice(),
inner: self.line_ranges.iter(),
}
}
}
/// Range delineating diagnostics in [`SortedDiagnostics`] that begin on a single line.
#[derive(Debug)]
struct LineDiagnosticRange {
line_number: OneIndexed,
diagnostic_index_range: Range<usize>,
}
/// Iterator to group sorted diagnostics by line.
pub(crate) struct LineDiagnosticsIterator<'a> {
diagnostics: &'a [&'a Diagnostic],
inner: std::slice::Iter<'a, LineDiagnosticRange>,
}
impl<'a> Iterator for LineDiagnosticsIterator<'a> {
type Item = LineDiagnostics<'a>;
fn next(&mut self) -> Option<Self::Item> {
let LineDiagnosticRange {
line_number,
diagnostic_index_range,
} = self.inner.next()?;
Some(LineDiagnostics {
line_number: *line_number,
diagnostics: &self.diagnostics[diagnostic_index_range.clone()],
})
}
}
impl std::iter::FusedIterator for LineDiagnosticsIterator<'_> {}
/// All diagnostics that start on a single line of source code in one embedded Python file.
#[derive(Debug)]
pub(crate) struct LineDiagnostics<'a> {
/// Line number on which these diagnostics start.
pub(crate) line_number: OneIndexed,
/// Diagnostics starting on this line.
pub(crate) diagnostics: &'a [&'a Diagnostic],
}
impl<'a> Deref for LineDiagnostics<'a> {
type Target = [&'a Diagnostic];
fn deref(&self) -> &Self::Target {
self.diagnostics
}
}
#[derive(Debug)]
struct DiagnosticWithLine<'a> {
line_number: OneIndexed,
diagnostic: &'a Diagnostic,
}
#[cfg(test)]
mod tests {
use crate::db::Db;
use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, LintName, Severity, Span};
use ruff_db::files::system_path_to_file;
use ruff_db::source::line_index;
use ruff_db::system::DbWithWritableSystem as _;
use ruff_source_file::OneIndexed;
use ruff_text_size::{TextRange, TextSize};
#[test]
fn sort_and_group() {
let mut db = Db::setup();
db.write_file("/src/test.py", "one\ntwo\n").unwrap();
let file = system_path_to_file(&db, "/src/test.py").unwrap();
let lines = line_index(&db, file);
let ranges = [
TextRange::new(TextSize::new(0), TextSize::new(1)),
TextRange::new(TextSize::new(5), TextSize::new(10)),
TextRange::new(TextSize::new(1), TextSize::new(7)),
];
let diagnostics: Vec<_> = ranges
.into_iter()
.map(|range| {
let mut diag = Diagnostic::new(
DiagnosticId::Lint(LintName::of("dummy")),
Severity::Error,
"dummy",
);
let span = Span::from(file).with_range(range);
diag.annotate(Annotation::primary(span));
diag
})
.collect();
let sorted = super::SortedDiagnostics::new(diagnostics.iter(), &lines);
let grouped = sorted.iter_lines().collect::<Vec<_>>();
let [line1, line2] = &grouped[..] else {
panic!("expected two lines");
};
assert_eq!(line1.line_number, OneIndexed::from_zero_indexed(0));
assert_eq!(line1.diagnostics.len(), 2);
assert_eq!(line2.line_number, OneIndexed::from_zero_indexed(1));
assert_eq!(line2.diagnostics.len(), 1);
}
}

View File

@@ -0,0 +1,86 @@
//! Hover type inference for mdtest assertions.
//!
//! This module provides functionality to extract hover assertions from comments, infer types at
//! specified positions, and generate hover check outputs for matching.
use ruff_db::files::File;
use ruff_db::parsed::parsed_module;
use ruff_db::source::{line_index, source_text};
use ruff_source_file::{PositionEncoding, SourceLocation};
use ruff_text_size::TextSize;
use ty_ide::find_goto_target;
use ty_python_semantic::SemanticModel;
use crate::assertion::{InlineFileAssertions, ParsedAssertion, UnparsedAssertion};
use crate::check_output::CheckOutput;
use crate::db::Db;
/// A hover result for testing `hover` assertions.
#[derive(Debug, Clone)]
pub(crate) struct HoverOutput {
/// The offset (within the entire file) where hover was requested
pub(crate) offset: TextSize,
/// The inferred type at that position
pub(crate) inferred_type: String,
}
/// Get the inferred type at a given position in a file. Returns None if no node is found at that
/// position or if the node has no inferred type.
///
/// This reuses much of the logic from [`ty_ide::hover`]. Unlike that function, we return types for
/// literals, which is useful for testing type inference in mdtest assertions.
fn infer_type_at_position(db: &Db, file: File, offset: TextSize) -> Option<String> {
let parsed = parsed_module(db, file).load(db);
let goto_target = find_goto_target(&parsed, offset)?;
let model = SemanticModel::new(db, file);
let ty = goto_target.inferred_type(&model)?;
Some(ty.display(db).to_string())
}
/// Generate hover outputs for all of the `hover` assertions in a file.
pub(crate) fn generate_hover_outputs_into(
db: &Db,
hover_outputs: &mut Vec<CheckOutput>,
file: File,
) {
let assertions = InlineFileAssertions::from_file(db, file);
let source = source_text(db, file);
let lines = line_index(db, file);
// Iterate through all assertion groups, which are already associated with their target line
for line_assertions in &assertions {
let target_line = line_assertions.line_number;
// Look for hover assertions in this line's assertions
for assertion in line_assertions.iter() {
if !matches!(assertion, UnparsedAssertion::Hover { .. }) {
continue;
}
let Ok(ParsedAssertion::Hover(hover)) = assertion.parse(&lines, &source) else {
// The matcher will catch and report incorrectly formatted `hover` assertions, so
// we can just skip them.
continue;
};
// Convert the column offset within the assertion's line into a byte offset within the
// entire file.
let hover_location = SourceLocation {
line: target_line,
character_offset: hover.column,
};
let hover_offset = lines.offset(hover_location, &source, PositionEncoding::Utf32);
// Get the inferred type at that position
let Some(inferred_type) = infer_type_at_position(db, file, hover_offset) else {
continue;
};
hover_outputs.push(CheckOutput::Hover(HoverOutput {
offset: hover_offset,
inferred_type,
}));
}
}
}

View File

@@ -1,6 +1,3 @@
use crate::config::Log;
use crate::db::Db;
use crate::parser::{BacktickOffsets, EmbeddedFileSourceMap};
use camino::Utf8Path;
use colored::Colorize;
use config::SystemKind;
@@ -22,16 +19,21 @@ use ty_python_semantic::{
PythonVersionWithSource, SearchPath, SearchPathSettings, SysPrefixPathOrigin, list_modules,
resolve_module,
};
use ty_static::EnvVars;
use crate::check_output::CheckOutput;
use crate::config::Log;
use crate::db::Db;
use crate::parser::{BacktickOffsets, EmbeddedFileSourceMap};
mod assertion;
mod check_output;
mod config;
mod db;
mod diagnostic;
mod hover;
mod matcher;
mod parser;
use ty_static::EnvVars;
/// Run `path` as a markdown test suite with given `title`.
///
/// Panic on test failure, and print failure details.
@@ -370,7 +372,13 @@ fn run_test(
.cmp(&right.rendering_sort_key(db))
});
let failure = match matcher::match_file(db, test_file.file, &diagnostics) {
// Collect all of the check outputs for this file, and verify that they match the
// assertions in the file.
let mut check_outputs: Vec<_> = (diagnostics.iter().cloned())
.map(CheckOutput::Diagnostic)
.collect();
hover::generate_hover_outputs_into(db, &mut check_outputs, test_file.file);
let failure = match matcher::match_file(db, test_file.file, &check_outputs) {
Ok(()) => None,
Err(line_failures) => Some(FileFailures {
backtick_offsets: test_file.backtick_offsets.clone(),

View File

@@ -12,8 +12,9 @@ use ruff_db::source::{SourceText, line_index, source_text};
use ruff_source_file::{LineIndex, OneIndexed};
use crate::assertion::{InlineFileAssertions, ParsedAssertion, UnparsedAssertion};
use crate::check_output::{CheckOutput, SortedCheckOutputs};
use crate::db::Db;
use crate::diagnostic::SortedDiagnostics;
use crate::hover::HoverOutput;
#[derive(Debug, Default)]
pub(super) struct FailuresByLine {
@@ -54,66 +55,66 @@ struct LineFailures {
pub(super) fn match_file(
db: &Db,
file: File,
diagnostics: &[Diagnostic],
check_outputs: &[CheckOutput],
) -> Result<(), FailuresByLine> {
// Parse assertions from comments in the file, and get diagnostics from the file; both
// Parse assertions from comments in the file, and get check outputs from the file; both
// ordered by line number.
let assertions = InlineFileAssertions::from_file(db, file);
let diagnostics = SortedDiagnostics::new(diagnostics, &line_index(db, file));
let check_outputs = SortedCheckOutputs::new(check_outputs, &line_index(db, file));
// Get iterators over assertions and diagnostics grouped by line, in ascending line order.
// Get iterators over assertions and check outputs grouped by line, in ascending line order.
let mut line_assertions = assertions.into_iter();
let mut line_diagnostics = diagnostics.iter_lines();
let mut line_outputs = check_outputs.iter_lines();
let mut current_assertions = line_assertions.next();
let mut current_diagnostics = line_diagnostics.next();
let mut current_outputs = line_outputs.next();
let matcher = Matcher::from_file(db, file);
let mut failures = FailuresByLine::default();
loop {
match (&current_assertions, &current_diagnostics) {
(Some(assertions), Some(diagnostics)) => {
match assertions.line_number.cmp(&diagnostics.line_number) {
match (&current_assertions, &current_outputs) {
(Some(assertions), Some(outputs)) => {
match assertions.line_number.cmp(&outputs.line_number) {
Ordering::Equal => {
// We have assertions and diagnostics on the same line; check for
// We have assertions and outputs on the same line; check for
// matches and error on any that don't match, then advance both
// iterators.
matcher
.match_line(diagnostics, assertions)
.match_line(outputs.outputs, assertions)
.unwrap_or_else(|messages| {
failures.push(assertions.line_number, messages);
});
current_assertions = line_assertions.next();
current_diagnostics = line_diagnostics.next();
current_outputs = line_outputs.next();
}
Ordering::Less => {
// We have assertions on an earlier line than diagnostics; report these
// We have assertions on an earlier line than outputs; report these
// assertions as all unmatched, and advance the assertions iterator.
failures.push(assertions.line_number, unmatched(assertions));
current_assertions = line_assertions.next();
}
Ordering::Greater => {
// We have diagnostics on an earlier line than assertions; report these
// diagnostics as all unmatched, and advance the diagnostics iterator.
failures.push(diagnostics.line_number, unmatched(diagnostics));
current_diagnostics = line_diagnostics.next();
// We have outputs on an earlier line than assertions; report these
// outputs as all unmatched, and advance the outputs iterator.
failures.push(outputs.line_number, unmatched(outputs.outputs));
current_outputs = line_outputs.next();
}
}
}
(Some(assertions), None) => {
// We've exhausted diagnostics but still have assertions; report these assertions
// We've exhausted outputs but still have assertions; report these assertions
// as unmatched and advance the assertions iterator.
failures.push(assertions.line_number, unmatched(assertions));
current_assertions = line_assertions.next();
}
(None, Some(diagnostics)) => {
// We've exhausted assertions but still have diagnostics; report these
// diagnostics as unmatched and advance the diagnostics iterator.
failures.push(diagnostics.line_number, unmatched(diagnostics));
current_diagnostics = line_diagnostics.next();
(None, Some(outputs)) => {
// We've exhausted assertions but still have outputs; report these
// outputs as unmatched and advance the outputs iterator.
failures.push(outputs.line_number, unmatched(outputs.outputs));
current_outputs = line_outputs.next();
}
// When we've exhausted both diagnostics and assertions, break.
// When we've exhausted both outputs and assertions, break.
(None, None) => break,
}
}
@@ -170,6 +171,45 @@ fn maybe_add_undefined_reveal_clarification(
}
}
impl Unmatched for &CheckOutput {
fn unmatched(&self) -> String {
match self {
CheckOutput::Diagnostic(diag) => diag.unmatched(),
CheckOutput::Hover(hover) => hover.unmatched(),
}
}
}
impl UnmatchedWithColumn for &CheckOutput {
fn unmatched_with_column(&self, column: OneIndexed) -> String {
match self {
CheckOutput::Diagnostic(diag) => diag.unmatched_with_column(column),
CheckOutput::Hover(hover) => hover.unmatched_with_column(column),
}
}
}
impl Unmatched for &HoverOutput {
fn unmatched(&self) -> String {
format!(
"{} hover result: {}",
"unexpected:".red(),
self.inferred_type
)
}
}
impl UnmatchedWithColumn for &HoverOutput {
fn unmatched_with_column(&self, column: OneIndexed) -> String {
format!(
"{} {} hover result: {}",
"unexpected:".red(),
column,
self.inferred_type
)
}
}
impl Unmatched for &Diagnostic {
fn unmatched(&self) -> String {
maybe_add_undefined_reveal_clarification(
@@ -224,23 +264,23 @@ impl Matcher {
}
}
/// Check a slice of [`Diagnostic`]s against a slice of
/// Check a slice of [`CheckOutput`]s against a slice of
/// [`UnparsedAssertion`]s.
///
/// Return vector of [`Unmatched`] for any unmatched diagnostics or
/// Return vector of [`Unmatched`] for any unmatched outputs or
/// assertions.
fn match_line<'a, 'b>(
&self,
diagnostics: &'a [&'a Diagnostic],
outputs: &'a [&'a CheckOutput],
assertions: &'a [UnparsedAssertion<'b>],
) -> Result<(), Vec<String>>
where
'b: 'a,
{
let mut failures = vec![];
let mut unmatched = diagnostics.to_vec();
let mut unmatched = outputs.to_vec();
for assertion in assertions {
match assertion.parse() {
match assertion.parse(&self.line_index, &self.source) {
Ok(assertion) => {
if !self.matches(&assertion, &mut unmatched) {
failures.push(assertion.unmatched());
@@ -251,8 +291,8 @@ impl Matcher {
}
}
}
for diagnostic in unmatched {
failures.push(diagnostic.unmatched_with_column(self.column(diagnostic)));
for output in unmatched {
failures.push(output.unmatched_with_column(self.column(output)));
}
if failures.is_empty() {
Ok(())
@@ -261,21 +301,28 @@ impl Matcher {
}
}
fn column(&self, diagnostic: &Diagnostic) -> OneIndexed {
diagnostic
.primary_span()
.and_then(|span| span.range())
.map(|range| {
fn column(&self, output: &CheckOutput) -> OneIndexed {
match output {
CheckOutput::Diagnostic(diag) => diag
.primary_span()
.and_then(|span| span.range())
.map(|range| {
self.line_index
.line_column(range.start(), &self.source)
.column
})
.unwrap_or(OneIndexed::from_zero_indexed(0)),
CheckOutput::Hover(hover) => {
self.line_index
.line_column(range.start(), &self.source)
.line_column(hover.offset, &self.source)
.column
})
.unwrap_or(OneIndexed::from_zero_indexed(0))
}
}
}
/// Check if `assertion` matches any [`Diagnostic`]s in `unmatched`.
/// Check if `assertion` matches any [`CheckOutput`]s in `unmatched`.
///
/// If so, return `true` and remove the matched diagnostics from `unmatched`. Otherwise, return
/// If so, return `true` and remove the matched outputs from `unmatched`. Otherwise, return
/// `false`.
///
/// An `Error` assertion can only match one diagnostic; even if it could match more than one,
@@ -283,16 +330,17 @@ impl Matcher {
///
/// A `Revealed` assertion must match a revealed-type diagnostic, and may also match an
/// undefined-reveal diagnostic, if present.
fn matches(&self, assertion: &ParsedAssertion, unmatched: &mut Vec<&Diagnostic>) -> bool {
fn matches(&self, assertion: &ParsedAssertion, unmatched: &mut Vec<&CheckOutput>) -> bool {
match assertion {
ParsedAssertion::Error(error) => {
let position = unmatched.iter().position(|diagnostic| {
let position = unmatched.iter().position(|output| {
let CheckOutput::Diagnostic(diagnostic) = output else {
return false;
};
let lint_name_matches = !error.rule.is_some_and(|rule| {
!(diagnostic.id().is_lint_named(rule) || diagnostic.id().as_str() == rule)
});
let column_matches = error
.column
.is_none_or(|col| col == self.column(diagnostic));
let column_matches = error.column.is_none_or(|col| col == self.column(output));
let message_matches = error.message_contains.is_none_or(|needle| {
diagnostic.concise_message().to_string().contains(needle)
});
@@ -351,7 +399,10 @@ impl Matcher {
let mut matched_revealed_type = None;
let mut matched_undefined_reveal = None;
for (index, diagnostic) in unmatched.iter().enumerate() {
for (index, output) in unmatched.iter().enumerate() {
let CheckOutput::Diagnostic(diagnostic) = output else {
continue;
};
if matched_revealed_type.is_none() && diagnostic_matches_reveal(diagnostic) {
matched_revealed_type = Some(index);
} else if matched_undefined_reveal.is_none()
@@ -372,6 +423,27 @@ impl Matcher {
});
matched_revealed_type.is_some()
}
ParsedAssertion::Hover(hover) => {
let expected_type = discard_todo_metadata(hover.expected_type);
// Find a hover output that matches the expected type
let position = unmatched.iter().position(|output| {
let CheckOutput::Hover(hover_output) = output else {
return false;
};
// Compare the inferred type with the expected type
let inferred_type = discard_todo_metadata(&hover_output.inferred_type);
inferred_type == expected_type
});
if let Some(position) = position {
unmatched.swap_remove(position);
true
} else {
false
}
}
}
}
}
@@ -379,6 +451,7 @@ impl Matcher {
#[cfg(test)]
mod tests {
use super::FailuresByLine;
use crate::check_output::CheckOutput;
use ruff_db::Db;
use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, Severity, Span};
use ruff_db::files::{File, system_path_to_file};
@@ -438,11 +511,11 @@ mod tests {
db.write_file("/src/test.py", source).unwrap();
let file = system_path_to_file(&db, "/src/test.py").unwrap();
let diagnostics: Vec<Diagnostic> = expected_diagnostics
let check_outputs: Vec<CheckOutput> = expected_diagnostics
.into_iter()
.map(|diagnostic| diagnostic.into_diagnostic(file))
.map(|diagnostic| CheckOutput::Diagnostic(diagnostic.into_diagnostic(file)))
.collect();
super::match_file(&db, file, &diagnostics)
super::match_file(&db, file, &check_outputs)
}
fn assert_fail(result: Result<(), FailuresByLine>, messages: &[(usize, &[&str])]) {