Compare commits
40 Commits
alex/into_
...
dcreager/h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb95e49f96 | ||
|
|
0817a367e1 | ||
|
|
34b463d0a1 | ||
|
|
b438631de2 | ||
|
|
fbc211f344 | ||
|
|
1810b7c7a3 | ||
|
|
39353da108 | ||
|
|
7b88440fc2 | ||
|
|
8426cc6915 | ||
|
|
c08a2d6d65 | ||
|
|
6ddf729864 | ||
|
|
eaba8bc61e | ||
|
|
b18d213869 | ||
|
|
847e5f0c68 | ||
|
|
41772466d5 | ||
|
|
180d9de472 | ||
|
|
adf58b6c19 | ||
|
|
b683da8cde | ||
|
|
6d1c549c4e | ||
|
|
c574dff6b0 | ||
|
|
8c12fcb927 | ||
|
|
a2eaf7ce26 | ||
|
|
c3626a6d74 | ||
|
|
8221450cbc | ||
|
|
93db8833ef | ||
|
|
51d5bc709d | ||
|
|
0198857224 | ||
|
|
35b568dd6b | ||
|
|
35a5fd767d | ||
|
|
ac1b68c56d | ||
|
|
ab261360e4 | ||
|
|
19600ecd51 | ||
|
|
319f5be78c | ||
|
|
6370aea644 | ||
|
|
9d8e35b165 | ||
|
|
5c75e91abe | ||
|
|
6e73b859ef | ||
|
|
11e5ecb91e | ||
|
|
be47fbe0f6 | ||
|
|
37effea8fd |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -4460,6 +4460,7 @@ dependencies = [
|
||||
"thiserror 2.0.16",
|
||||
"toml",
|
||||
"tracing",
|
||||
"ty_ide",
|
||||
"ty_python_semantic",
|
||||
"ty_static",
|
||||
"ty_vendored",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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};
|
||||
|
||||
140
crates/ty_python_semantic/resources/mdtest/hover.md
Normal file
140
crates/ty_python_semantic/resources/mdtest/hover.md
Normal 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)]
|
||||
```
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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]"
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
204
crates/ty_test/src/check_output.rs
Normal file
204
crates/ty_test/src/check_output.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
86
crates/ty_test/src/hover.rs
Normal file
86
crates/ty_test/src/hover.rs
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 (¤t_assertions, ¤t_diagnostics) {
|
||||
(Some(assertions), Some(diagnostics)) => {
|
||||
match assertions.line_number.cmp(&diagnostics.line_number) {
|
||||
match (¤t_assertions, ¤t_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])]) {
|
||||
|
||||
Reference in New Issue
Block a user