Skip to content

Commit 6f8959e

Browse files
antonsyndclaude
andcommitted
feat(lsp): narrow hover highlight spans for definitions and return keyword
Hover highlights previously spanned the entire AST node, causing multi-line highlighting for definitions and 'return self' highlighting for return statements. Now definition-site hovers narrow to just the name identifier, and return statements narrow to the 6-char keyword. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0e016a3 commit 6f8959e

3 files changed

Lines changed: 183 additions & 0 deletions

File tree

src/Sharpy.Lsp.Tests/E2E/ProtocolTests.cs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,4 +901,89 @@ await _client.WaitForNotificationAsync(
901901
s!["name"]?.GetValue<string>()?.Contains("animal", StringComparison.OrdinalIgnoreCase) == true);
902902
hasAnimal.Should().BeTrue("at least one symbol should have a name containing 'animal'");
903903
}
904+
905+
[Fact]
906+
public async Task Hover_OverMethodName_RangeNarrowedToName()
907+
{
908+
await _client.InitializeAsync();
909+
910+
var uri = "file:///test_hover_narrow_method.spy";
911+
// Mirrors async_with_basic.spy structure
912+
var source = "class AsyncResource:\n def __init__(self):\n pass\n\n async def __aenter__(self) -> AsyncResource:\n print(\"entering\")\n return self\n\n async def __aexit__(self):\n print(\"exiting\")\n\nasync def main():\n async with AsyncResource() as r:\n print(\"inside\")";
913+
await _client.DidOpenAsync(uri, source);
914+
915+
// Wait for diagnostics to ensure analysis is complete
916+
await _client.WaitForNotificationAsync(
917+
"textDocument/publishDiagnostics",
918+
TimeSpan.FromSeconds(15));
919+
920+
// Hover over '__aenter__' on line 4 (0-based), character 14 (0-based)
921+
// Source line 5 (1-based): " async def __aenter__(self) -> AsyncResource:"
922+
// 0-based: 0123456789012345
923+
// ^ col 14 = start of "__aenter__"
924+
var hover = await _client.HoverAsync(uri, 4, 14);
925+
926+
hover.Should().NotBeNull("hover over a method name should return information");
927+
928+
var contents = hover!["contents"];
929+
contents.Should().NotBeNull();
930+
var hoverText = contents!.ToJsonString();
931+
hoverText.Should().Contain("__aenter__");
932+
933+
// Verify the range is narrowed to just the method name, not the whole function body
934+
var range = hover["range"];
935+
range.Should().NotBeNull("hover should include a range");
936+
937+
var startLine = range!["start"]!["line"]!.GetValue<int>();
938+
var startChar = range["start"]!["character"]!.GetValue<int>();
939+
var endLine = range["end"]!["line"]!.GetValue<int>();
940+
var endChar = range["end"]!["character"]!.GetValue<int>();
941+
942+
startLine.Should().Be(4, "highlight should start on the method name line");
943+
endLine.Should().Be(4, "highlight should end on the same line (not span multi-line)");
944+
startChar.Should().Be(14, "highlight should start at '__aenter__'");
945+
endChar.Should().Be(24, "highlight should end after '__aenter__' (10 chars)");
946+
}
947+
948+
[Fact]
949+
public async Task Hover_OverReturnKeyword_RangeNarrowedToKeyword()
950+
{
951+
await _client.InitializeAsync();
952+
953+
var uri = "file:///test_hover_narrow_return.spy";
954+
var source = "def greet() -> str:\n return \"hello\"\ndef main():\n greet()";
955+
await _client.DidOpenAsync(uri, source);
956+
957+
// Wait for diagnostics to ensure analysis is complete
958+
await _client.WaitForNotificationAsync(
959+
"textDocument/publishDiagnostics",
960+
TimeSpan.FromSeconds(15));
961+
962+
// Hover over 'return' on line 1 (0-based), character 4 (0-based)
963+
// Source line 2 (1-based): " return \"hello\""
964+
// 0-based: 01234
965+
// ^ col 4 = start of "return"
966+
var hover = await _client.HoverAsync(uri, 1, 4);
967+
968+
hover.Should().NotBeNull("hover over return keyword should return information");
969+
970+
var contents = hover!["contents"];
971+
contents.Should().NotBeNull();
972+
var hoverText = contents!.ToJsonString();
973+
hoverText.Should().Contain("return");
974+
975+
// Verify the range is narrowed to just the 'return' keyword
976+
var range = hover["range"];
977+
range.Should().NotBeNull("hover should include a range");
978+
979+
var startLine = range!["start"]!["line"]!.GetValue<int>();
980+
var startChar = range["start"]!["character"]!.GetValue<int>();
981+
var endLine = range["end"]!["line"]!.GetValue<int>();
982+
var endChar = range["end"]!["character"]!.GetValue<int>();
983+
984+
startLine.Should().Be(1, "highlight should start on the return line");
985+
endLine.Should().Be(1, "highlight should end on the same line");
986+
startChar.Should().Be(4, "highlight should start at 'return'");
987+
endChar.Should().Be(10, "highlight should end after 'return' (6 chars)");
988+
}
904989
}

src/Sharpy.Lsp.Tests/HoverServiceTests.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,4 +437,65 @@ public void GetHoverResult_OverNotKeyword_NarrowsHighlightToKeyword()
437437
hover.HighlightColumnStart.Should().Be(9);
438438
hover.HighlightColumnEnd.Should().Be(12);
439439
}
440+
441+
// --- Highlight narrowing: definition names ---
442+
443+
[Fact]
444+
public void GetHoverResult_OverFunctionName_NarrowsHighlightToName()
445+
{
446+
var source = "class AsyncResource:\n def __init__(self):\n pass\n\n async def __aenter__(self) -> AsyncResource:\n print(\"entering\")\n return self\n\n async def __aexit__(self):\n print(\"exiting\")\n\ndef main():\n pass";
447+
var result = _api.Analyze(source);
448+
449+
// Line 5: " async def __aenter__(self) -> AsyncResource:"
450+
// Cols: 123456789012345678
451+
// '__aenter__' starts at col 15
452+
var hover = _hoverService.GetHoverResult(result, 5, 15);
453+
454+
hover.Should().NotBeNull();
455+
hover!.Markdown.Should().Contain("__aenter__");
456+
// Highlight should be narrowed to just the function name, not the whole FunctionDef
457+
hover.HighlightLineStart.Should().Be(5);
458+
hover.HighlightColumnStart.Should().Be(15);
459+
hover.HighlightLineEnd.Should().Be(5);
460+
hover.HighlightColumnEnd.Should().Be(25); // "__aenter__" = 10 chars
461+
}
462+
463+
[Fact]
464+
public void GetHoverResult_OverClassName_NarrowsHighlightToName()
465+
{
466+
var source = "class Foo:\n x: int = 0\n def method(self) -> int:\n return self.x\ndef main():\n pass";
467+
var result = _api.Analyze(source);
468+
469+
// Line 1: "class Foo:"
470+
// 'Foo' starts at col 7
471+
var hover = _hoverService.GetHoverResult(result, 1, 7);
472+
473+
hover.Should().NotBeNull();
474+
hover!.Markdown.Should().Contain("Foo");
475+
hover.HighlightLineStart.Should().Be(1);
476+
hover.HighlightColumnStart.Should().Be(7);
477+
hover.HighlightLineEnd.Should().Be(1);
478+
hover.HighlightColumnEnd.Should().Be(10); // "Foo" = 3 chars
479+
}
480+
481+
// --- Highlight narrowing: return keyword ---
482+
483+
[Fact]
484+
public void GetHoverResult_OverReturnKeyword_NarrowsHighlightToKeyword()
485+
{
486+
var source = "def greet() -> str:\n return \"hello\"\ndef main():\n pass";
487+
var result = _api.Analyze(source);
488+
489+
// Line 2: " return \"hello\"" — 'return' starts at col 5
490+
var hover = _hoverService.GetHoverResult(result, 2, 5);
491+
492+
hover.Should().NotBeNull();
493+
hover!.Markdown.Should().Contain("return");
494+
hover.Markdown.Should().Contain("str");
495+
// Highlight should be narrowed to just the 'return' keyword (6 chars)
496+
hover.HighlightLineStart.Should().Be(2);
497+
hover.HighlightColumnStart.Should().Be(5);
498+
hover.HighlightLineEnd.Should().Be(2);
499+
hover.HighlightColumnEnd.Should().Be(11); // "return" = 6 chars
500+
}
440501
}

src/Sharpy.Lsp/HoverService.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,18 @@ public sealed record HoverResult(string Markdown, Node Node)
7373
if (markdown == null)
7474
return null;
7575

76+
// Narrow highlight to the name span for definition-site hovers,
77+
// or to the keyword token for keyword statements like 'return'.
78+
var highlight = TryNarrowHighlight(node, line, col);
79+
if (highlight != null)
80+
return new HoverResult(markdown, node)
81+
{
82+
HighlightLineStart = highlight.Value.line,
83+
HighlightColumnStart = highlight.Value.colStart,
84+
HighlightLineEnd = highlight.Value.line,
85+
HighlightColumnEnd = highlight.Value.colEnd,
86+
};
87+
7688
return new HoverResult(markdown, node);
7789
}
7890

@@ -121,6 +133,31 @@ public sealed record HoverResult(string Markdown, Node Node)
121133
};
122134
}
123135

136+
/// <summary>
137+
/// Narrows the hover highlight range for definition nodes (to the name identifier)
138+
/// and keyword statements like <c>return</c> (to the keyword token). Returns null
139+
/// when no narrowing applies.
140+
/// </summary>
141+
private static (int line, int colStart, int colEnd)? TryNarrowHighlight(Node node, int cursorLine, int cursorCol)
142+
{
143+
return node switch
144+
{
145+
FunctionDef f when IsOnHeaderName(cursorLine, cursorCol, f.NameLineStart, f.NameColumnStart, f.Name)
146+
=> (f.NameLineStart, f.NameColumnStart, f.NameColumnStart + f.Name.Length),
147+
ClassDef c when IsOnHeaderName(cursorLine, cursorCol, c.NameLineStart, c.NameColumnStart, c.Name)
148+
=> (c.NameLineStart, c.NameColumnStart, c.NameColumnStart + c.Name.Length),
149+
StructDef s when IsOnHeaderName(cursorLine, cursorCol, s.NameLineStart, s.NameColumnStart, s.Name)
150+
=> (s.NameLineStart, s.NameColumnStart, s.NameColumnStart + s.Name.Length),
151+
InterfaceDef i when IsOnHeaderName(cursorLine, cursorCol, i.NameLineStart, i.NameColumnStart, i.Name)
152+
=> (i.NameLineStart, i.NameColumnStart, i.NameColumnStart + i.Name.Length),
153+
EnumDef e when IsOnHeaderName(cursorLine, cursorCol, e.NameLineStart, e.NameColumnStart, e.Name)
154+
=> (e.NameLineStart, e.NameColumnStart, e.NameColumnStart + e.Name.Length),
155+
ReturnStatement ret
156+
=> (ret.LineStart, ret.ColumnStart, ret.ColumnStart + 6), // "return"
157+
_ => null
158+
};
159+
}
160+
124161
private static bool IsInsideComment(IReadOnlyList<CommentSpan> spans, int line, int col)
125162
{
126163
if (spans == null || spans.Count == 0)

0 commit comments

Comments
 (0)