Skip to content

Commit 584dfed

Browse files
authored
Add hover language service providers (#81)
2 parents 9023424 + cd08819 commit 584dfed

18 files changed

+677
-11
lines changed

src/App/Lab/LanguageServicesClient.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ internal sealed class LanguageServicesClient(
1414
BlazorMonacoInterop blazorMonacoInterop)
1515
{
1616
private Dictionary<string, string> modelUrlToFileName = [];
17-
private IDisposable? completionProvider, semanticTokensProvider, codeActionProvider;
17+
private IDisposable? completionProvider, semanticTokensProvider, codeActionProvider, hoverProvider, signatureHelpProvider;
1818
private string? currentModelUrl;
1919
private DebounceInfo completionDebounce = new(new CancellationTokenSource());
2020
private DebounceInfo diagnosticsDebounce = new(new CancellationTokenSource());
@@ -128,6 +128,16 @@ private async Task RegisterAsync()
128128
return result;
129129
},
130130
});
131+
132+
hoverProvider = await blazorMonacoInterop.RegisterHoverProviderAsync(cSharpLanguageSelector, new(loggerFactory)
133+
{
134+
ProvideHover = worker.ProvideHoverAsync,
135+
});
136+
137+
signatureHelpProvider = await blazorMonacoInterop.RegisterSignatureHelpProviderAsync(cSharpLanguageSelector, new(loggerFactory)
138+
{
139+
ProvideSignatureHelp = worker.ProvideSignatureHelpAsync,
140+
});
131141
}
132142

133143
private void Unregister()
@@ -138,6 +148,10 @@ private void Unregister()
138148
semanticTokensProvider = null;
139149
codeActionProvider?.Dispose();
140150
codeActionProvider = null;
151+
hoverProvider?.Dispose();
152+
hoverProvider = null;
153+
signatureHelpProvider?.Dispose();
154+
signatureHelpProvider = null;
141155
InvalidateCaches();
142156
}
143157

src/App/Lab/Page.razor

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,10 @@
103103
</CascadingValue>
104104

105105
@* Input / output panels *@
106+
@* Panels have `overflow: hidden` by default, which would cause Monaco Editor popups to get cropped, hence we reset it to `overflow: initial`. *@
106107
<FluentMultiSplitter Orientation="orientation" Style="flex-grow: 1; overflow: hidden">
107108
@* Input panel *@
108-
<FluentMultiSplitterPane Collapsible>
109+
<FluentMultiSplitterPane Collapsible Style="overflow: initial">
109110
<div style="display: flex; flex-direction: column; height: 100%">
110111
@* Input tabs *@
111112
<FluentTabs @bind-ActiveTabId="activeInputTabId" @bind-ActiveTabId:after="OnActiveInputTabIdChangedAsync"
@@ -161,7 +162,7 @@
161162
</FluentMultiSplitterPane>
162163

163164
@* Output panel *@
164-
<FluentMultiSplitterPane Collapsible>
165+
<FluentMultiSplitterPane Collapsible Style="overflow: initial">
165166
<div style="display: flex; flex-direction: column; height: 100%">
166167
@* Worker status message *@
167168
@if (workerError != null)

src/App/Lab/WorkerController.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,22 @@ public Task<string> ProvideCompletionItemsAsync(string modelUri, Position positi
469469
cancellationToken: cancellationToken);
470470
}
471471

472+
public Task<string?> ProvideHoverAsync(string modelUri, string positionJson, CancellationToken cancellationToken)
473+
{
474+
return PostAndReceiveMessageAsync(
475+
new WorkerInputMessage.ProvideHover(modelUri, positionJson) { Id = messageId++ },
476+
deserializeAs: default(string),
477+
cancellationToken: cancellationToken);
478+
}
479+
480+
public Task<string?> ProvideSignatureHelpAsync(string modelUri, string positionJson, string contextJson, CancellationToken cancellationToken)
481+
{
482+
return PostAndReceiveMessageAsync(
483+
new WorkerInputMessage.ProvideSignatureHelp(modelUri, positionJson, contextJson) { Id = messageId++ },
484+
deserializeAs: default(string),
485+
cancellationToken: cancellationToken);
486+
}
487+
472488
public void OnDidChangeWorkspace(ImmutableArray<ModelInfo> models)
473489
{
474490
PostMessage(

src/App/Utils/Monaco/BlazorMonacoInterop.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ private static partial JSObject RegisterCodeActionProvider(
3636
string language,
3737
[JSMarshalAs<JSType.Any>] object provider);
3838

39+
[JSImport("registerHoverProvider", moduleName)]
40+
private static partial JSObject RegisterHoverProvider(
41+
string language,
42+
[JSMarshalAs<JSType.Any>] object provider);
43+
44+
[JSImport("registerSignatureHelpProvider", moduleName)]
45+
private static partial JSObject RegisterSignatureHelpProvider(
46+
string language,
47+
[JSMarshalAs<JSType.Any>] object provider);
48+
3949
[JSImport("dispose", moduleName)]
4050
private static partial void DisposeDisposable(JSObject disposable);
4151

@@ -97,6 +107,31 @@ internal static async Task<string> ProvideCompletionItemsAsync(
97107
return json;
98108
}
99109

110+
[JSExport]
111+
internal static async Task<string?> ProvideHoverAsync(
112+
[JSMarshalAs<JSType.Any>] object providerReference,
113+
string modelUri,
114+
string positionJson,
115+
JSObject token)
116+
{
117+
var provider = ((DotNetObjectReference<HoverProvider>)providerReference).Value;
118+
string? json = await provider.ProvideHover(modelUri, positionJson, ToCancellationToken(token, provider.Logger));
119+
return json;
120+
}
121+
122+
[JSExport]
123+
internal static async Task<string?> ProvideSignatureHelpAsync(
124+
[JSMarshalAs<JSType.Any>] object providerReference,
125+
string modelUri,
126+
string positionJson,
127+
string contextJson,
128+
JSObject token)
129+
{
130+
var provider = ((DotNetObjectReference<SignatureHelpProvider>)providerReference).Value;
131+
string? json = await provider.ProvideSignatureHelp(modelUri, positionJson, contextJson, ToCancellationToken(token, provider.Logger));
132+
return json;
133+
}
134+
100135
public async Task<IDisposable> RegisterCompletionProviderAsync(
101136
LanguageSelector language,
102137
CompletionItemProviderAsync completionItemProvider)
@@ -138,6 +173,28 @@ public async Task<IDisposable> RegisterCodeActionProviderAsync(
138173
return new Disposable(disposable);
139174
}
140175

176+
public async Task<IDisposable> RegisterHoverProviderAsync(
177+
LanguageSelector language,
178+
HoverProvider provider)
179+
{
180+
await EnsureInitializedAsync();
181+
JSObject disposable = RegisterHoverProvider(
182+
JsonSerializer.Serialize(language, BlazorMonacoJsonContext.Default.LanguageSelector),
183+
DotNetObjectReference.Create(provider));
184+
return new Disposable(disposable);
185+
}
186+
187+
public async Task<IDisposable> RegisterSignatureHelpProviderAsync(
188+
LanguageSelector language,
189+
SignatureHelpProvider provider)
190+
{
191+
await EnsureInitializedAsync();
192+
JSObject disposable = RegisterSignatureHelpProvider(
193+
JsonSerializer.Serialize(language, BlazorMonacoJsonContext.Default.LanguageSelector),
194+
DotNetObjectReference.Create(provider));
195+
return new Disposable(disposable);
196+
}
197+
141198
public static CancellationToken ToCancellationToken(JSObject token, ILogger logger, [CallerMemberName] string memberName = "")
142199
{
143200
// No need to initialize, we already must have called other APIs.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace DotNetLab;
2+
3+
internal sealed class HoverProvider(ILoggerFactory loggerFactory)
4+
{
5+
public ILogger<HoverProvider> Logger { get; } = loggerFactory.CreateLogger<HoverProvider>();
6+
7+
public delegate Task<string?> ProvideHoverDelegate(
8+
string modelUri,
9+
string positionJson,
10+
CancellationToken cancellationToken);
11+
12+
public required ProvideHoverDelegate ProvideHover { get; init; }
13+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace DotNetLab;
2+
3+
internal sealed class SignatureHelpProvider(ILoggerFactory loggerFactory)
4+
{
5+
public ILogger<SignatureHelpProvider> Logger { get; } = loggerFactory.CreateLogger<SignatureHelpProvider>();
6+
7+
public delegate Task<string?> ProvideSignatureHelpDelegate(
8+
string modelUri,
9+
string positionJson,
10+
string contextJson,
11+
CancellationToken cancellationToken);
12+
13+
public required ProvideSignatureHelpDelegate ProvideSignatureHelp { get; init; }
14+
}

src/App/wwwroot/js/BlazorMonacoInterop.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,60 @@ export function registerCodeActionProvider(language, codeActionProvider) {
134134
});
135135
}
136136

137+
export function registerHoverProvider(language, hoverProvider) {
138+
// https://microsoft.github.io/monaco-editor/typedoc/functions/languages.registerHoverProvider.html
139+
return monaco.languages.registerHoverProvider(JSON.parse(language), {
140+
provideHover: async (model, position, token, context) => {
141+
const result = await globalThis.DotNetLab.BlazorMonacoInterop.ProvideHoverAsync(
142+
hoverProvider, decodeURI(model.uri.toString()), JSON.stringify(position), token);
143+
144+
if (result === null) {
145+
// If null result is returned, it means the request should be ignored, so we need to throw
146+
// (as opposed to returning no code actions).
147+
// The text 'busy' is recommended for this purpose (e.g., it avoids sending telemetry).
148+
throw new Error('busy');
149+
}
150+
151+
return { contents: [{ value: result }] };
152+
},
153+
});
154+
}
155+
156+
export function registerSignatureHelpProvider(language, hoverProvider) {
157+
// https://microsoft.github.io/monaco-editor/typedoc/functions/languages.registerSignatureHelpProvider.html
158+
return monaco.languages.registerSignatureHelpProvider(JSON.parse(language), {
159+
signatureHelpTriggerCharacters: ['(', ','],
160+
provideSignatureHelp: async (model, position, token, context) => {
161+
const contextLight = {
162+
...context,
163+
// Avoid sending currently unused data.
164+
activeSignatureHelp: undefined,
165+
};
166+
167+
const result = await globalThis.DotNetLab.BlazorMonacoInterop.ProvideSignatureHelpAsync(
168+
hoverProvider, decodeURI(model.uri.toString()), JSON.stringify(position), JSON.stringify(contextLight), token);
169+
170+
if (result === null) {
171+
// If null result is returned, it means the request should be ignored, so we need to throw
172+
// (as opposed to returning no code actions).
173+
// The text 'busy' is recommended for this purpose (e.g., it avoids sending telemetry).
174+
throw new Error('busy');
175+
}
176+
177+
const parsed = JSON.parse(result);
178+
179+
if (parsed === null) {
180+
return null;
181+
}
182+
183+
return {
184+
value: parsed,
185+
dispose: () => { }, // Currently not used.
186+
};
187+
},
188+
});
189+
}
190+
137191
/**
138192
* @param {monaco.IDisposable} disposable
139193
*/

src/Compiler/LanguageServices.cs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Microsoft.CodeAnalysis.Completion;
88
using Microsoft.CodeAnalysis.Diagnostics;
99
using Microsoft.CodeAnalysis.Host.Mef;
10+
using Microsoft.CodeAnalysis.QuickInfo;
1011
using Microsoft.CodeAnalysis.Text;
1112
using Microsoft.Extensions.Logging;
1213
using System.Runtime.CompilerServices;
@@ -471,6 +472,87 @@ static string addPrefix(string? prefix, string nested)
471472
}
472473
}
473474

475+
/// <returns>
476+
/// Markdown or <see langword="null"/> if cancelled.
477+
/// </returns>
478+
/// <remarks>
479+
/// For inspiration, see <see href="https://github.com/dotnet/roslyn/blob/ad14335550de1134f0b5a59b6cd040001d0d8c8d/src/LanguageServer/Protocol/Handler/Hover/HoverHandler.cs#L26"/>.
480+
/// </remarks>
481+
public async Task<string?> ProvideHoverAsync(string modelUri, string positionJson, CancellationToken cancellationToken)
482+
{
483+
if (!TryGetDocument(modelUri, out var document))
484+
{
485+
return "";
486+
}
487+
488+
var sw = Stopwatch.StartNew();
489+
var position = JsonSerializer.Deserialize(positionJson, BlazorMonacoJsonContext.Default.Position)!;
490+
try
491+
{
492+
var text = await document.GetTextAsync(cancellationToken);
493+
int caretPosition = text.Lines.GetPosition(position.ToLinePosition());
494+
var quickInfoService = document.Project.Services.GetRequiredService<QuickInfoService>();
495+
var quickInfo = await quickInfoService.GetQuickInfoAsync(document, caretPosition, cancellationToken);
496+
if (quickInfo == null)
497+
{
498+
return "";
499+
}
500+
501+
// Insert line breaks in between sections to ensure we get double spacing between sections.
502+
var tags = quickInfo.Sections.SelectMany(static s =>
503+
s.TaggedParts.Add(new TaggedText(TextTags.LineBreak, Environment.NewLine)));
504+
505+
var markdown = MonacoConversions.GetMarkdown(tags, document.Project.Language);
506+
507+
logger.LogDebug("Got hover ({Length}) for {Position} in {Time} ms", markdown.Length, position.Stringify(), sw.ElapsedMilliseconds.SeparateThousands());
508+
509+
return markdown;
510+
}
511+
catch (OperationCanceledException)
512+
{
513+
logger.LogDebug("Canceled hover for {Position} in {Time} ms", position.Stringify(), sw.ElapsedMilliseconds.SeparateThousands());
514+
515+
return null;
516+
}
517+
}
518+
519+
/// <returns>
520+
/// JSON-serialized <see cref="SignatureHelp"/>.
521+
/// We serialize here to avoid serializing twice unnecessarily
522+
/// (first on Worker to App interface, then on App to Monaco interface).
523+
/// </returns>
524+
/// <remarks>
525+
/// For inspiration, see <see href="https://github.com/dotnet/roslyn/blob/ad14335550de1134f0b5a59b6cd040001d0d8c8d/src/LanguageServer/Protocol/Handler/SignatureHelp/SignatureHelpHandler.cs#L25"/>.
526+
/// </remarks>
527+
public async Task<string?> ProvideSignatureHelpAsync(string modelUri, string positionJson, string contextJson, CancellationToken cancellationToken)
528+
{
529+
if (!TryGetDocument(modelUri, out var document))
530+
{
531+
return "null";
532+
}
533+
534+
var sw = Stopwatch.StartNew();
535+
var position = JsonSerializer.Deserialize(positionJson, BlazorMonacoJsonContext.Default.Position)!;
536+
try
537+
{
538+
var text = await document.GetTextAsync(cancellationToken);
539+
int caretPosition = text.Lines.GetPosition(position.ToLinePosition());
540+
var context = JsonSerializer.Deserialize(contextJson, BlazorMonacoJsonContext.Default.SignatureHelpContext)!;
541+
var signatureHelp = await document.GetSignatureHelpAsync(caretPosition, context.ToReason(), context.TriggerCharacter, cancellationToken);
542+
var signatureHelpJson = JsonSerializer.Serialize(signatureHelp, BlazorMonacoJsonContext.Default.SignatureHelp);
543+
544+
logger.LogDebug("Got signature help ({Length}) for {Position} in {Time} ms", signatureHelpJson.Length, position.Stringify(), sw.ElapsedMilliseconds.SeparateThousands());
545+
546+
return signatureHelpJson;
547+
}
548+
catch (OperationCanceledException)
549+
{
550+
logger.LogDebug("Canceled signature help for {Position} in {Time} ms", position.Stringify(), sw.ElapsedMilliseconds.SeparateThousands());
551+
552+
return null;
553+
}
554+
}
555+
474556
public async void OnCompilationFinished()
475557
{
476558
try

0 commit comments

Comments
 (0)