Add hover assertion type to mdtest framework

- Add Hover variant to UnparsedAssertion and ParsedAssertion enums
- Create HoverAssertion struct with column and expected_type fields
- Add HoverAssertionParseError enum for validation errors
- Update from_comment() to recognize '# hover:' and '# ↓ hover:' patterns
- Simplified design: down arrow must appear immediately before 'hover' keyword
- Add placeholder matching logic in matcher.rs (to be completed)
- Add PLAN.md to track implementation progress

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Douglas Creager 2025-10-08 12:19:21 -04:00
parent 3771f1567c
commit 37effea8fd
3 changed files with 181 additions and 2 deletions

120
PLAN.md Normal file
View File

@ -0,0 +1,120 @@
# Hover Assertions in Mdtest Framework
## Goal
Add support for hover assertions in the mdtest framework. These assertions will verify the inferred type of an expression at a specific position, similar to how hover works in a language server.
## Current Architecture
### ty_ide hover tests
- Use `<CURSOR>` marker in test source code (hover.rs:161-167)
- Call `hover(db, file, offset)` to get type at cursor position (hover.rs:12-50)
- Use `find_goto_target()` to find the covering AST node (hover.rs:14)
- Return the inferred type via `SemanticModel` (hover.rs:22-23)
### mdtest framework
- Parses markdown files with Python code blocks (parser.rs)
- Supports two assertion types via comments (assertion.rs:242-248):
- `# error:` - matches diagnostics
- `# revealed:` - matches revealed-type diagnostics
- Assertions can be:
- End-of-line: `x: int = "foo" # error: [invalid-assignment]`
- Preceding line: `# error: [invalid-assignment]\nx: int = "foo"`
- Matcher logic in matcher.rs compares assertions against diagnostics
## Implementation Plan
### 1. Add new assertion type (assertion.rs)
**Status:** ✅ Completed
- [x] Add `Hover(&'a str)` variant to `UnparsedAssertion` enum (line 242)
- [x] Update `from_comment()` to recognize `# hover:` and `# ↓ hover:` (line 253)
- [x] Add `Hover(HoverAssertion<'a>)` to `ParsedAssertion` enum (line 294)
- [x] Add `HoverAssertion` struct with column and expected_type fields
- [x] Add `HoverAssertionParseError` enum
- [x] Update parsing logic to validate hover assertions (line 267)
### 2. Extend assertion parsing to capture column position
**Status:** ✅ Simplified approach
- [x] Hover assertions MUST be preceding-line only (not end-of-line)
- [x] Down arrow must appear immediately before `hover` keyword (e.g., `# ↓ hover:`)
- [x] Column position determined by whitespace before `#` in comment
- [x] Calculate TextSize offset from: (target_line_start + down_arrow_column)
### 3. Create hover diagnostic type (diagnostic.rs)
**Status:** Not started
- [ ] Add new diagnostic ID for hover results (similar to `RevealedType`)
- [ ] Store the position and inferred type as a diagnostic
- [ ] This allows reuse of existing matcher infrastructure
### 4. Add hover checking logic (lib.rs)
**Status:** Not started
- [ ] After type checking, for each hover assertion:
- [ ] Find the target line (first non-assertion line after hover comment)
- [ ] Calculate position from down arrow column
- [ ] Call `hover(db, file, position)` from ty_ide
- [ ] Create a "hover diagnostic" with the result
- [ ] Add to diagnostics list for matching
### 5. Update matcher (matcher.rs)
**Status:** In progress
- [x] Add placeholder matching logic for `ParsedAssertion::Hover`
- [ ] Compute column position from comment range when matching
- [ ] Match against hover diagnostics by:
- [ ] Position (must match exactly)
- [ ] Type string (must match the assertion body)
- [ ] Handle `@Todo` metadata stripping (line 201-212)
### 6. Add tests
**Status:** Not started
- [ ] Add unit tests for hover assertion parsing
- [ ] Create mdtest file with hover assertion examples
- [ ] Test edge cases (no hover content, wrong position, etc.)
## Key Design Decisions
1. **Preceding-line only**: Hover assertions make sense only as preceding-line comments, since we need to identify both line and column via the down arrow.
2. **Down arrow syntax**: `# ↓ hover: int` where the arrow column identifies the hover position. This is intuitive and visual.
3. **Reuse diagnostic infrastructure**: By converting hover results to diagnostics, we leverage the existing matcher framework rather than creating parallel logic.
4. **Similar to revealed assertions**: The implementation will closely mirror the `revealed:` assertion logic, as both check inferred types.
## Example Usage
```python
# Test basic type inference
a = 10
# ↓ hover: Literal[10]
a
# Test function type
def foo() -> int: ...
# ↓ hover: def foo() -> int
foo
```
## Files to Modify
1. `crates/ty_test/src/assertion.rs` - Add `Hover` assertion type
2. `crates/ty_test/src/lib.rs` - Add hover checking logic
3. `crates/ty_test/src/matcher.rs` - Add hover matching logic
4. `crates/ty_test/src/diagnostic.rs` - Add hover diagnostic type (if needed)
5. Tests in existing mdtest files to validate
## Progress Log
- **2025-10-08**: Initial plan created based on codebase analysis
- **2025-10-08**: Completed step 1 - Added hover assertion type to assertion.rs
- Added `Hover` variant to `UnparsedAssertion` and `ParsedAssertion` enums
- Created `HoverAssertion` struct and `HoverAssertionParseError` enum
- Updated `from_comment()` to recognize `# hover:` and `# ↓ hover:` patterns
- 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)

View File

@ -245,10 +245,13 @@ pub(crate) enum UnparsedAssertion<'a> {
/// An `# error:` assertion.
Error(&'a str),
/// A `# hover:` assertion, with the full comment text including the down arrow.
Hover(&'a str),
}
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();
@ -259,6 +262,7 @@ impl<'a> UnparsedAssertion<'a> {
match keyword {
"revealed" => Some(Self::Revealed(body)),
"error" => Some(Self::Error(body)),
"hover" | "↓ hover" => Some(Self::Hover(body)),
_ => None,
}
}
@ -276,6 +280,9 @@ impl<'a> UnparsedAssertion<'a> {
Self::Error(error) => ErrorAssertion::from_str(error)
.map(ParsedAssertion::Error)
.map_err(PragmaParseError::ErrorAssertionParseError),
Self::Hover(hover) => HoverAssertion::from_str(hover)
.map(ParsedAssertion::Hover)
.map_err(PragmaParseError::HoverAssertionParseError),
}
}
}
@ -285,6 +292,7 @@ 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}"),
}
}
}
@ -297,6 +305,9 @@ pub(crate) enum ParsedAssertion<'a> {
/// An `# error:` assertion.
Error(ErrorAssertion<'a>),
/// A `# hover:` assertion.
Hover(HoverAssertion<'a>),
}
impl std::fmt::Display for ParsedAssertion<'_> {
@ -304,6 +315,7 @@ 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),
}
}
}
@ -343,6 +355,38 @@ impl std::fmt::Display for ErrorAssertion<'_> {
}
}
/// A parsed and validated `# hover:` assertion comment.
#[derive(Debug)]
pub(crate) struct HoverAssertion<'a> {
/// The column where the down arrow appears in the assertion comment.
/// This indicates the position in the next 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(source: &'a str) -> Result<Self, HoverAssertionParseError> {
if source.is_empty() {
return Err(HoverAssertionParseError::EmptyType);
}
// Column will be computed from the comment position in the matcher
// For now, we just validate and store the expected type
Ok(Self {
column: OneIndexed::from_zero_indexed(0), // Placeholder, will be set by matcher
expected_type: source,
})
}
}
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,13 +498,15 @@ 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.
/// The assertion comment could be a "revealed", "error", or "hover" assertion.
#[derive(Debug, 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.
@ -486,6 +532,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, 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::*;

View File

@ -372,6 +372,10 @@ impl Matcher {
});
matched_revealed_type.is_some()
}
ParsedAssertion::Hover(_hover) => {
// TODO: Implement hover matching once hover diagnostic infrastructure is in place
false
}
}
}
}