diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000000..32b8e595ab --- /dev/null +++ b/PLAN.md @@ -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 `` 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) diff --git a/crates/ty_test/src/assertion.rs b/crates/ty_test/src/assertion.rs index e5b7baaf6d..beb2f29735 100644 --- a/crates/ty_test/src/assertion.rs +++ b/crates/ty_test/src/assertion.rs @@ -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 { 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 { + 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::*; diff --git a/crates/ty_test/src/matcher.rs b/crates/ty_test/src/matcher.rs index 39fe8633ca..437eef4377 100644 --- a/crates/ty_test/src/matcher.rs +++ b/crates/ty_test/src/matcher.rs @@ -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 + } } } }