diff --git a/PLAN.md b/PLAN.md index 1cbf4d52af..b716f7bb71 100644 --- a/PLAN.md +++ b/PLAN.md @@ -43,12 +43,14 @@ Add support for hover assertions in the mdtest framework. These assertions will - [x] Calculate TextSize offset from: (target_line_start + down_arrow_column) ### 3. Create CheckOutput enum (matcher.rs) -**Status:** In progress +**Status:** ✅ Completed - [x] Add `CheckOutput` enum with `Diagnostic` and `Hover` variants -- [ ] Update `match_file` to accept `&[CheckOutput]` instead of `&[Diagnostic]` -- [ ] Create `SortedCheckOutputs` similar to `SortedDiagnostics` -- [ ] Update matching logic to extract line numbers from CheckOutput variants +- [x] Update `match_file` to accept `&[CheckOutput]` instead of `&[Diagnostic]` +- [x] Create `SortedCheckOutputs` similar to `SortedDiagnostics` +- [x] Update matching logic to extract line numbers from CheckOutput variants +- [x] Implement `Unmatched` trait for `CheckOutput` +- [x] Update lib.rs to convert diagnostics to `CheckOutput` before matching ### 4. Add hover checking logic (lib.rs) **Status:** Not started @@ -119,3 +121,10 @@ def foo() -> int: ... - Simplified approach: down arrow must appear immediately before `hover` keyword - Added placeholder matching logic in matcher.rs (TODO: implement once diagnostics ready) - ty_test compiles successfully with warnings (unused code, expected at this stage) +- **2025-10-08**: Completed step 3 - Created CheckOutput enum infrastructure + - Decided NOT to add HoverType to DiagnosticId (keep test logic separate) + - Created `CheckOutput` enum with `Diagnostic` and `Hover` variants + - Implemented `SortedCheckOutputs` to handle sorting/grouping by line + - Updated entire matcher module to work with `CheckOutput` instead of `Diagnostic` + - Updated lib.rs to convert diagnostics to `CheckOutput` before matching + - All changes compile successfully diff --git a/crates/ty_test/src/lib.rs b/crates/ty_test/src/lib.rs index cdf054cbf1..9faf9d5d15 100644 --- a/crates/ty_test/src/lib.rs +++ b/crates/ty_test/src/lib.rs @@ -20,8 +20,10 @@ use ty_python_semantic::types::check_types; use ty_python_semantic::{ Module, Program, ProgramSettings, PythonEnvironment, PythonPlatform, PythonVersionSource, PythonVersionWithSource, SearchPath, SearchPathSettings, SysPrefixPathOrigin, list_modules, - resolve_module, + resolve_module, SemanticModel, }; +use ruff_python_ast::{AnyNodeRef, visitor::source_order::{SourceOrderVisitor, TraversalSignal}}; +use ruff_text_size::{Ranged, TextSize}; mod assertion; mod config; @@ -32,6 +34,151 @@ mod parser; use ty_static::EnvVars; +/// Find the AST node with minimal range that fully contains the given offset. +/// This is a simplified version of ty_ide's covering_node logic. +fn find_covering_node<'a>(root: AnyNodeRef<'a>, offset: TextSize) -> Option> { + struct Visitor<'a> { + offset: TextSize, + found: Option>, + } + + impl<'a> SourceOrderVisitor<'a> for Visitor<'a> { + fn enter_node(&mut self, node: AnyNodeRef<'a>) -> TraversalSignal { + if node.range().contains(self.offset) { + self.found = Some(node); + TraversalSignal::Traverse + } else { + TraversalSignal::Skip + } + } + } + + let mut visitor = Visitor { + offset, + found: None, + }; + + root.visit_source_order(&mut visitor); + visitor.found +} + +/// 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 type. +fn infer_type_at_position(db: &Db, file: File, offset: TextSize) -> Option { + use ty_python_semantic::HasType; + + let parsed = parsed_module(db, file).load(db); + let ast = parsed.syntax(); + let root: AnyNodeRef = ast.into(); + + let node = find_covering_node(root, offset)?; + + let model = SemanticModel::new(db, file); + + // Try to get the type from the node - HasType is mainly implemented for ast types + let ty = match node { + AnyNodeRef::StmtFunctionDef(s) => s.inferred_type(&model), + AnyNodeRef::StmtClassDef(s) => s.inferred_type(&model), + AnyNodeRef::StmtExpr(s) => s.value.as_ref().inferred_type(&model), + AnyNodeRef::ExprBoolOp(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprNamed(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprBinOp(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprUnaryOp(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprLambda(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprIf(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprDict(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprSet(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprListComp(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprSetComp(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprDictComp(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprGenerator(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprAwait(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprYield(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprYieldFrom(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprCompare(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprCall(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprFString(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprStringLiteral(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprBytesLiteral(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprNumberLiteral(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprBooleanLiteral(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprNoneLiteral(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprEllipsisLiteral(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprAttribute(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprSubscript(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprStarred(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprName(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprList(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprTuple(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprSlice(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + AnyNodeRef::ExprIpyEscapeCommand(e) => ruff_python_ast::ExprRef::from(e).inferred_type(&model), + _ => return None, + }; + + Some(ty.display(db).to_string()) +} + +/// Generate hover CheckOutputs for all hover assertions in a file. +/// +/// This scans the file for hover assertions (comments with `# ↓ hover:`), +/// computes the hover position from the down arrow location, calls the type +/// inference, and returns CheckOutput::Hover entries. +fn generate_hover_outputs(db: &Db, file: File) -> Vec { + use ruff_db::source::{source_text, line_index}; + use ruff_python_trivia::CommentRanges; + + let source = source_text(db, file); + let lines = line_index(db, file); + let parsed = parsed_module(db, file).load(db); + let comment_ranges = CommentRanges::from(parsed.tokens()); + + let mut hover_outputs = Vec::new(); + + for comment_range in &comment_ranges { + let comment_text = &source[comment_range]; + + // Check if this is a hover assertion (contains "# ↓ hover:" or "# hover:") + if !comment_text.trim().starts_with('#') { + continue; + } + + let trimmed = comment_text.trim().strip_prefix('#').unwrap().trim(); + if !trimmed.starts_with("↓ hover:") && !trimmed.starts_with("hover:") { + continue; + } + + // Find the down arrow position in the comment + let arrow_offset = comment_text.find('↓'); + if arrow_offset.is_none() { + // No down arrow means we can't determine the column + continue; + } + let arrow_column = arrow_offset.unwrap(); + + // Get the line number of the comment + let comment_line = lines.line_index(comment_range.start()); + + // The hover target is the next non-comment, non-empty line + let target_line = comment_line.saturating_add(1); + + // Get the start offset of the target line + let target_line_start = lines.line_start(target_line, &source); + + // Calculate the hover position: start of target line + arrow column + let hover_offset = target_line_start + TextSize::try_from(arrow_column).unwrap(); + + // Get the inferred type at that position + if let Some(inferred_type) = infer_type_at_position(db, file, hover_offset) { + hover_outputs.push(matcher::CheckOutput::Hover { + offset: hover_offset, + inferred_type, + }); + } + } + + hover_outputs +} + /// Run `path` as a markdown test suite with given `title`. /// /// Panic on test failure, and print failure details. @@ -371,11 +518,14 @@ fn run_test( }); // Convert diagnostics to CheckOutput - let check_outputs: Vec = diagnostics + let mut check_outputs: Vec = diagnostics .iter() .map(|diag| matcher::CheckOutput::Diagnostic(diag.clone())) .collect(); + // Generate and add hover outputs + check_outputs.extend(generate_hover_outputs(db, test_file.file)); + let failure = match matcher::match_file(db, test_file.file, &check_outputs) { Ok(()) => None, Err(line_failures) => Some(FileFailures { diff --git a/crates/ty_test/src/matcher.rs b/crates/ty_test/src/matcher.rs index 6466cb27b3..5b28c15fcb 100644 --- a/crates/ty_test/src/matcher.rs +++ b/crates/ty_test/src/matcher.rs @@ -535,9 +535,29 @@ impl Matcher { }); matched_revealed_type.is_some() } - ParsedAssertion::Hover(_hover) => { - // TODO: Implement hover matching once hover diagnostic infrastructure is in place - false + 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 { + inferred_type, .. + } = output + else { + return false; + }; + + // Compare the inferred type with the expected type + let inferred_type = discard_todo_metadata(inferred_type); + inferred_type == expected_type + }); + + if let Some(position) = position { + unmatched.swap_remove(position); + true + } else { + false + } } } }