diff --git a/Cargo.lock b/Cargo.lock index 212f965b45af8..19cb524cba728 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4460,6 +4460,7 @@ dependencies = [ "thiserror 2.0.16", "toml", "tracing", + "ty_ide", "ty_python_semantic", "ty_static", "ty_vendored", diff --git a/crates/ty_ide/src/goto.rs b/crates/ty_ide/src/goto.rs index bb085e3c3c0d0..dd7fb89bdc439 100644 --- a/crates/ty_ide/src/goto.rs +++ b/crates/ty_ide/src/goto.rs @@ -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> { + pub fn inferred_type<'db>(&self, model: &SemanticModel<'db>) -> Option> { 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> { +pub fn find_goto_target(parsed: &ParsedModuleRef, offset: TextSize) -> Option> { let token = parsed .tokens() .at_offset(offset) diff --git a/crates/ty_ide/src/lib.rs b/crates/ty_ide/src/lib.rs index 9febfb06ece00..dcd6be5de407c 100644 --- a/crates/ty_ide/src/lib.rs +++ b/crates/ty_ide/src/lib.rs @@ -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}; diff --git a/crates/ty_python_semantic/resources/mdtest/hover.md b/crates/ty_python_semantic/resources/mdtest/hover.md new file mode 100644 index 0000000000000..b846ddddec25a --- /dev/null +++ b/crates/ty_python_semantic/resources/mdtest/hover.md @@ -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)] +``` diff --git a/crates/ty_test/Cargo.toml b/crates/ty_test/Cargo.toml index 97bd4bea2cef5..4f3dfb5c0b588 100644 --- a/crates/ty_test/Cargo.toml +++ b/crates/ty_test/Cargo.toml @@ -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 } diff --git a/crates/ty_test/src/assertion.rs b/crates/ty_test/src/assertion.rs index e5b7baaf6d158..dbbfeb7378575 100644 --- a/crates/ty_test/src/assertion.rs +++ b/crates/ty_test/src/assertion.rs @@ -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 { - let comment = comment.trim().strip_prefix('#')?.trim(); - let (keyword, body) = comment.split_once(':')?; + fn from_comment(comment: &'a str, range: TextRange) -> Option { + 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, PragmaParseError<'a>> { + pub(crate) fn parse( + &self, + line_index: &ruff_source_file::LineIndex, + source: &ruff_db::source::SourceText, + ) -> Result, 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 { + 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]" + })) + ); + } } diff --git a/crates/ty_test/src/check_output.rs b/crates/ty_test/src/check_output.rs new file mode 100644 index 0000000000000..85631fbc25315 --- /dev/null +++ b/crates/ty_test/src/check_output.rs @@ -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, +} + +impl<'a> SortedCheckOutputs<'a> { + pub(crate) fn new( + outputs: impl IntoIterator, + 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, +} + +/// 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 { + 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::>(); + + 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); + } +} diff --git a/crates/ty_test/src/diagnostic.rs b/crates/ty_test/src/diagnostic.rs deleted file mode 100644 index f869f999ab0cb..0000000000000 --- a/crates/ty_test/src/diagnostic.rs +++ /dev/null @@ -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, -} - -impl<'a> SortedDiagnostics<'a> { - pub(crate) fn new( - diagnostics: impl IntoIterator, - 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, -} - -/// 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 { - 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::>(); - - 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); - } -} diff --git a/crates/ty_test/src/hover.rs b/crates/ty_test/src/hover.rs new file mode 100644 index 0000000000000..77ec94fd0028a --- /dev/null +++ b/crates/ty_test/src/hover.rs @@ -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 { + 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, + 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, + })); + } + } +} diff --git a/crates/ty_test/src/lib.rs b/crates/ty_test/src/lib.rs index 8b03002b20ff0..d50dc92bd8273 100644 --- a/crates/ty_test/src/lib.rs +++ b/crates/ty_test/src/lib.rs @@ -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(), diff --git a/crates/ty_test/src/matcher.rs b/crates/ty_test/src/matcher.rs index 39fe8633cab1b..ae7619e3e1d6a 100644 --- a/crates/ty_test/src/matcher.rs +++ b/crates/ty_test/src/matcher.rs @@ -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> 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 = expected_diagnostics + let check_outputs: Vec = 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])]) {