diff --git a/src/App/Lab/LanguageServicesClient.cs b/src/App/Lab/LanguageServicesClient.cs index d3e61a75..cdc731f6 100644 --- a/src/App/Lab/LanguageServicesClient.cs +++ b/src/App/Lab/LanguageServicesClient.cs @@ -14,7 +14,7 @@ internal sealed class LanguageServicesClient( BlazorMonacoInterop blazorMonacoInterop) { private Dictionary modelUrlToFileName = []; - private IDisposable? completionProvider, semanticTokensProvider, codeActionProvider; + private IDisposable? completionProvider, semanticTokensProvider, codeActionProvider, hoverProvider, signatureHelpProvider; private string? currentModelUrl; private DebounceInfo completionDebounce = new(new CancellationTokenSource()); private DebounceInfo diagnosticsDebounce = new(new CancellationTokenSource()); @@ -128,6 +128,16 @@ private async Task RegisterAsync() return result; }, }); + + hoverProvider = await blazorMonacoInterop.RegisterHoverProviderAsync(cSharpLanguageSelector, new(loggerFactory) + { + ProvideHover = worker.ProvideHoverAsync, + }); + + signatureHelpProvider = await blazorMonacoInterop.RegisterSignatureHelpProviderAsync(cSharpLanguageSelector, new(loggerFactory) + { + ProvideSignatureHelp = worker.ProvideSignatureHelpAsync, + }); } private void Unregister() @@ -138,6 +148,10 @@ private void Unregister() semanticTokensProvider = null; codeActionProvider?.Dispose(); codeActionProvider = null; + hoverProvider?.Dispose(); + hoverProvider = null; + signatureHelpProvider?.Dispose(); + signatureHelpProvider = null; InvalidateCaches(); } diff --git a/src/App/Lab/Page.razor b/src/App/Lab/Page.razor index a5910eea..ca6dc5f0 100644 --- a/src/App/Lab/Page.razor +++ b/src/App/Lab/Page.razor @@ -103,9 +103,10 @@ @* Input / output panels *@ +@* Panels have `overflow: hidden` by default, which would cause Monaco Editor popups to get cropped, hence we reset it to `overflow: initial`. *@ @* Input panel *@ - +
@* Input tabs *@ @* Output panel *@ - +
@* Worker status message *@ @if (workerError != null) diff --git a/src/App/Lab/WorkerController.cs b/src/App/Lab/WorkerController.cs index 3b796770..9a0476fe 100644 --- a/src/App/Lab/WorkerController.cs +++ b/src/App/Lab/WorkerController.cs @@ -469,6 +469,22 @@ public Task ProvideCompletionItemsAsync(string modelUri, Position positi cancellationToken: cancellationToken); } + public Task ProvideHoverAsync(string modelUri, string positionJson, CancellationToken cancellationToken) + { + return PostAndReceiveMessageAsync( + new WorkerInputMessage.ProvideHover(modelUri, positionJson) { Id = messageId++ }, + deserializeAs: default(string), + cancellationToken: cancellationToken); + } + + public Task ProvideSignatureHelpAsync(string modelUri, string positionJson, string contextJson, CancellationToken cancellationToken) + { + return PostAndReceiveMessageAsync( + new WorkerInputMessage.ProvideSignatureHelp(modelUri, positionJson, contextJson) { Id = messageId++ }, + deserializeAs: default(string), + cancellationToken: cancellationToken); + } + public void OnDidChangeWorkspace(ImmutableArray models) { PostMessage( diff --git a/src/App/Utils/Monaco/BlazorMonacoInterop.cs b/src/App/Utils/Monaco/BlazorMonacoInterop.cs index cbf395ea..52f0eeb3 100644 --- a/src/App/Utils/Monaco/BlazorMonacoInterop.cs +++ b/src/App/Utils/Monaco/BlazorMonacoInterop.cs @@ -36,6 +36,16 @@ private static partial JSObject RegisterCodeActionProvider( string language, [JSMarshalAs] object provider); + [JSImport("registerHoverProvider", moduleName)] + private static partial JSObject RegisterHoverProvider( + string language, + [JSMarshalAs] object provider); + + [JSImport("registerSignatureHelpProvider", moduleName)] + private static partial JSObject RegisterSignatureHelpProvider( + string language, + [JSMarshalAs] object provider); + [JSImport("dispose", moduleName)] private static partial void DisposeDisposable(JSObject disposable); @@ -97,6 +107,31 @@ internal static async Task ProvideCompletionItemsAsync( return json; } + [JSExport] + internal static async Task ProvideHoverAsync( + [JSMarshalAs] object providerReference, + string modelUri, + string positionJson, + JSObject token) + { + var provider = ((DotNetObjectReference)providerReference).Value; + string? json = await provider.ProvideHover(modelUri, positionJson, ToCancellationToken(token, provider.Logger)); + return json; + } + + [JSExport] + internal static async Task ProvideSignatureHelpAsync( + [JSMarshalAs] object providerReference, + string modelUri, + string positionJson, + string contextJson, + JSObject token) + { + var provider = ((DotNetObjectReference)providerReference).Value; + string? json = await provider.ProvideSignatureHelp(modelUri, positionJson, contextJson, ToCancellationToken(token, provider.Logger)); + return json; + } + public async Task RegisterCompletionProviderAsync( LanguageSelector language, CompletionItemProviderAsync completionItemProvider) @@ -138,6 +173,28 @@ public async Task RegisterCodeActionProviderAsync( return new Disposable(disposable); } + public async Task RegisterHoverProviderAsync( + LanguageSelector language, + HoverProvider provider) + { + await EnsureInitializedAsync(); + JSObject disposable = RegisterHoverProvider( + JsonSerializer.Serialize(language, BlazorMonacoJsonContext.Default.LanguageSelector), + DotNetObjectReference.Create(provider)); + return new Disposable(disposable); + } + + public async Task RegisterSignatureHelpProviderAsync( + LanguageSelector language, + SignatureHelpProvider provider) + { + await EnsureInitializedAsync(); + JSObject disposable = RegisterSignatureHelpProvider( + JsonSerializer.Serialize(language, BlazorMonacoJsonContext.Default.LanguageSelector), + DotNetObjectReference.Create(provider)); + return new Disposable(disposable); + } + public static CancellationToken ToCancellationToken(JSObject token, ILogger logger, [CallerMemberName] string memberName = "") { // No need to initialize, we already must have called other APIs. diff --git a/src/App/Utils/Monaco/HoverProvider.cs b/src/App/Utils/Monaco/HoverProvider.cs new file mode 100644 index 00000000..cef2dddd --- /dev/null +++ b/src/App/Utils/Monaco/HoverProvider.cs @@ -0,0 +1,13 @@ +namespace DotNetLab; + +internal sealed class HoverProvider(ILoggerFactory loggerFactory) +{ + public ILogger Logger { get; } = loggerFactory.CreateLogger(); + + public delegate Task ProvideHoverDelegate( + string modelUri, + string positionJson, + CancellationToken cancellationToken); + + public required ProvideHoverDelegate ProvideHover { get; init; } +} diff --git a/src/App/Utils/Monaco/SignatureHelpProvider.cs b/src/App/Utils/Monaco/SignatureHelpProvider.cs new file mode 100644 index 00000000..85e4eabe --- /dev/null +++ b/src/App/Utils/Monaco/SignatureHelpProvider.cs @@ -0,0 +1,14 @@ +namespace DotNetLab; + +internal sealed class SignatureHelpProvider(ILoggerFactory loggerFactory) +{ + public ILogger Logger { get; } = loggerFactory.CreateLogger(); + + public delegate Task ProvideSignatureHelpDelegate( + string modelUri, + string positionJson, + string contextJson, + CancellationToken cancellationToken); + + public required ProvideSignatureHelpDelegate ProvideSignatureHelp { get; init; } +} diff --git a/src/App/wwwroot/js/BlazorMonacoInterop.js b/src/App/wwwroot/js/BlazorMonacoInterop.js index be030fa1..74d1d50d 100644 --- a/src/App/wwwroot/js/BlazorMonacoInterop.js +++ b/src/App/wwwroot/js/BlazorMonacoInterop.js @@ -134,6 +134,60 @@ export function registerCodeActionProvider(language, codeActionProvider) { }); } +export function registerHoverProvider(language, hoverProvider) { + // https://microsoft.github.io/monaco-editor/typedoc/functions/languages.registerHoverProvider.html + return monaco.languages.registerHoverProvider(JSON.parse(language), { + provideHover: async (model, position, token, context) => { + const result = await globalThis.DotNetLab.BlazorMonacoInterop.ProvideHoverAsync( + hoverProvider, decodeURI(model.uri.toString()), JSON.stringify(position), token); + + if (result === null) { + // If null result is returned, it means the request should be ignored, so we need to throw + // (as opposed to returning no code actions). + // The text 'busy' is recommended for this purpose (e.g., it avoids sending telemetry). + throw new Error('busy'); + } + + return { contents: [{ value: result }] }; + }, + }); +} + +export function registerSignatureHelpProvider(language, hoverProvider) { + // https://microsoft.github.io/monaco-editor/typedoc/functions/languages.registerSignatureHelpProvider.html + return monaco.languages.registerSignatureHelpProvider(JSON.parse(language), { + signatureHelpTriggerCharacters: ['(', ','], + provideSignatureHelp: async (model, position, token, context) => { + const contextLight = { + ...context, + // Avoid sending currently unused data. + activeSignatureHelp: undefined, + }; + + const result = await globalThis.DotNetLab.BlazorMonacoInterop.ProvideSignatureHelpAsync( + hoverProvider, decodeURI(model.uri.toString()), JSON.stringify(position), JSON.stringify(contextLight), token); + + if (result === null) { + // If null result is returned, it means the request should be ignored, so we need to throw + // (as opposed to returning no code actions). + // The text 'busy' is recommended for this purpose (e.g., it avoids sending telemetry). + throw new Error('busy'); + } + + const parsed = JSON.parse(result); + + if (parsed === null) { + return null; + } + + return { + value: parsed, + dispose: () => { }, // Currently not used. + }; + }, + }); +} + /** * @param {monaco.IDisposable} disposable */ diff --git a/src/Compiler/LanguageServices.cs b/src/Compiler/LanguageServices.cs index b894697f..5375413d 100644 --- a/src/Compiler/LanguageServices.cs +++ b/src/Compiler/LanguageServices.cs @@ -7,6 +7,7 @@ using Microsoft.CodeAnalysis.Completion; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.QuickInfo; using Microsoft.CodeAnalysis.Text; using Microsoft.Extensions.Logging; using System.Runtime.CompilerServices; @@ -471,6 +472,87 @@ static string addPrefix(string? prefix, string nested) } } + /// + /// Markdown or if cancelled. + /// + /// + /// For inspiration, see . + /// + public async Task ProvideHoverAsync(string modelUri, string positionJson, CancellationToken cancellationToken) + { + if (!TryGetDocument(modelUri, out var document)) + { + return ""; + } + + var sw = Stopwatch.StartNew(); + var position = JsonSerializer.Deserialize(positionJson, BlazorMonacoJsonContext.Default.Position)!; + try + { + var text = await document.GetTextAsync(cancellationToken); + int caretPosition = text.Lines.GetPosition(position.ToLinePosition()); + var quickInfoService = document.Project.Services.GetRequiredService(); + var quickInfo = await quickInfoService.GetQuickInfoAsync(document, caretPosition, cancellationToken); + if (quickInfo == null) + { + return ""; + } + + // Insert line breaks in between sections to ensure we get double spacing between sections. + var tags = quickInfo.Sections.SelectMany(static s => + s.TaggedParts.Add(new TaggedText(TextTags.LineBreak, Environment.NewLine))); + + var markdown = MonacoConversions.GetMarkdown(tags, document.Project.Language); + + logger.LogDebug("Got hover ({Length}) for {Position} in {Time} ms", markdown.Length, position.Stringify(), sw.ElapsedMilliseconds.SeparateThousands()); + + return markdown; + } + catch (OperationCanceledException) + { + logger.LogDebug("Canceled hover for {Position} in {Time} ms", position.Stringify(), sw.ElapsedMilliseconds.SeparateThousands()); + + return null; + } + } + + /// + /// JSON-serialized . + /// We serialize here to avoid serializing twice unnecessarily + /// (first on Worker to App interface, then on App to Monaco interface). + /// + /// + /// For inspiration, see . + /// + public async Task ProvideSignatureHelpAsync(string modelUri, string positionJson, string contextJson, CancellationToken cancellationToken) + { + if (!TryGetDocument(modelUri, out var document)) + { + return "null"; + } + + var sw = Stopwatch.StartNew(); + var position = JsonSerializer.Deserialize(positionJson, BlazorMonacoJsonContext.Default.Position)!; + try + { + var text = await document.GetTextAsync(cancellationToken); + int caretPosition = text.Lines.GetPosition(position.ToLinePosition()); + var context = JsonSerializer.Deserialize(contextJson, BlazorMonacoJsonContext.Default.SignatureHelpContext)!; + var signatureHelp = await document.GetSignatureHelpAsync(caretPosition, context.ToReason(), context.TriggerCharacter, cancellationToken); + var signatureHelpJson = JsonSerializer.Serialize(signatureHelp, BlazorMonacoJsonContext.Default.SignatureHelp); + + logger.LogDebug("Got signature help ({Length}) for {Position} in {Time} ms", signatureHelpJson.Length, position.Stringify(), sw.ElapsedMilliseconds.SeparateThousands()); + + return signatureHelpJson; + } + catch (OperationCanceledException) + { + logger.LogDebug("Canceled signature help for {Position} in {Time} ms", position.Stringify(), sw.ElapsedMilliseconds.SeparateThousands()); + + return null; + } + } + public async void OnCompilationFinished() { try diff --git a/src/Compiler/Utils/MonacoConversions.cs b/src/Compiler/Utils/MonacoConversions.cs index e3efcf21..688a22bf 100644 --- a/src/Compiler/Utils/MonacoConversions.cs +++ b/src/Compiler/Utils/MonacoConversions.cs @@ -7,6 +7,7 @@ global using RoslynCompletionRules = Microsoft.CodeAnalysis.Completion.CompletionRules; global using RoslynCompletionTrigger = Microsoft.CodeAnalysis.Completion.CompletionTrigger; global using RoslynCompletionTriggerKind = Microsoft.CodeAnalysis.Completion.CompletionTriggerKind; +global using Environment = System.Environment; using BlazorMonaco; using BlazorMonaco.Editor; @@ -140,6 +141,131 @@ static void ConvertToSingleLineSpan( } } + private const string CSharpMarkdownLanguageName = "csharp"; + private const string VisualBasicMarkdownLanguageName = "vb"; + private const string BlockCodeFence = "```"; + private const string InlineCodeFence = "`"; + + /// + /// Inspired by . + /// + public static string GetMarkdown(IEnumerable tags, string language) + { + var markdownBuilder = new MarkdownContentBuilder(); + string? codeFence = null; + foreach (var taggedText in tags) + { + switch (taggedText.Tag) + { + case TextTagsInternal.CodeBlockStart: + if (markdownBuilder.IsLineEmpty()) + { + // If the current line is empty, we can append a code block. + codeFence = BlockCodeFence; + var codeBlockLanguageName = GetCodeBlockLanguageName(language); + markdownBuilder.AppendLine($"{codeFence}{codeBlockLanguageName}"); + markdownBuilder.AppendLine(taggedText.Text); + } + else + { + // There is text on the line already - we should append an in-line code block. + codeFence = InlineCodeFence; + markdownBuilder.Append(codeFence + taggedText.Text); + } + + break; + case TextTagsInternal.CodeBlockEnd: + if (codeFence == BlockCodeFence) + { + markdownBuilder.AppendLine(codeFence); + markdownBuilder.AppendLine(taggedText.Text); + } + else if (codeFence == InlineCodeFence) + { + markdownBuilder.Append(codeFence + taggedText.Text); + } + else + { + throw Util.Unexpected(codeFence); + } + + codeFence = null; + + break; + case TextTags.Text when taggedText.Style == (TaggedTextStylePublic.Code | TaggedTextStylePublic.PreserveWhitespace): + // This represents a block of code (``) in doc comments. + // Since code elements optionally support a `lang` attribute and we do not have access to the + // language which was specified at this point, we tell the client to render it as plain text. + + if (!markdownBuilder.IsLineEmpty()) + AppendLineBreak(markdownBuilder); + + // The current line is empty, we can append a code block. + markdownBuilder.AppendLine($"{BlockCodeFence}text"); + markdownBuilder.AppendLine(taggedText.Text); + markdownBuilder.AppendLine(BlockCodeFence); + + break; + case TextTags.LineBreak: + AppendLineBreak(markdownBuilder); + break; + default: + var styledText = GetStyledText(taggedText, codeFence != null); + markdownBuilder.Append(styledText); + break; + } + } + + var content = markdownBuilder.Build(Environment.NewLine); + + return content; + + static void AppendLineBreak(MarkdownContentBuilder markdownBuilder) + { + // A line ending with double space and a new line indicates to markdown + // to render a single-spaced line break. + markdownBuilder.Append(" "); + markdownBuilder.AppendLine(); + } + + static string GetCodeBlockLanguageName(string language) + { + return language switch + { + (LanguageNames.CSharp) => CSharpMarkdownLanguageName, + (LanguageNames.VisualBasic) => VisualBasicMarkdownLanguageName, + _ => throw new InvalidOperationException($"{language} is not supported"), + }; + } + + static string GetStyledText(TaggedText taggedText, bool isInCodeBlock) + { + var isCode = isInCodeBlock || taggedText.Style is TaggedTextStylePublic.Code; + var text = isCode ? taggedText.Text : MonacoPatterns.MarkdownEscapeRegex.Replace(taggedText.Text, @"\$1"); + + // For non-cref links, the URI is present in both the hint and target. + if (!string.IsNullOrEmpty(taggedText.NavigationHint) && taggedText.NavigationHint == taggedText.NavigationTarget) + return $"[{text}]({taggedText.NavigationHint})"; + + // Markdown ignores spaces at the start of lines outside of code blocks, + // so we replace regular spaces with non-breaking spaces to ensure structural space is retained. + // We want to use regular spaces everywhere else to allow the client to wrap long text. + if (!isCode && taggedText.Tag is TextTags.Space or TextTagsInternal.ContainerStart) + text = text.Replace(" ", " "); + + return taggedText.Style switch + { + TaggedTextStylePublic.None => text, + TaggedTextStylePublic.Strong => $"**{text}**", + TaggedTextStylePublic.Emphasis => $"_{text}_", + TaggedTextStylePublic.Underline => $"{text}", + // Use double backticks to escape code which contains a backtick. + TaggedTextStylePublic.Code => text.Contains('`') ? $"``{text}``" : $"`{text}`", + _ => text, + }; + } + } + public static TextSpan GetTextSpan(this ModelContentChange change) { return new TextSpan(change.RangeOffset, change.RangeLength); @@ -286,6 +412,22 @@ public static MonacoRange ToRange(this TextSpan span, TextLineCollection lines) return lines.GetLinePositionSpan(span).ToRange(); } + public static SignatureHelpTriggerReasonPublic ToReason(this SignatureHelpContext context) + { + if (context.IsRetrigger) + { + return SignatureHelpTriggerReasonPublic.RetriggerCommand; + } + + if (context.TriggerKind is SignatureHelpTriggerKind.TriggerCharacter + or SignatureHelpTriggerKind.ContentChange) + { + return SignatureHelpTriggerReasonPublic.TypeCharCommand; + } + + return SignatureHelpTriggerReasonPublic.InvokeSignatureHelpCommand; + } + public static TextSpan ToSpan(this MonacoRange range, TextLineCollection lines) { return lines.GetTextSpan(range.ToLinePositionSpan()); @@ -379,3 +521,54 @@ internal sealed class ClassifiedSpanComparer : IComparer public int Compare(ClassifiedSpan x, ClassifiedSpan y) => x.TextSpan.CompareTo(y.TextSpan); } + +internal static partial class MonacoPatterns +{ + [GeneratedRegex(@"([\\`\*_\{\}\[\]\(\)#+\-\.!<>])")] + public static partial Regex MarkdownEscapeRegex { get; } +} + +internal readonly ref struct MarkdownContentBuilder +{ + private readonly ImmutableArray.Builder linesBuilder; + + public MarkdownContentBuilder() + { + linesBuilder = ImmutableArray.CreateBuilder(); + } + + public void Append(string text) + { + if (linesBuilder.Count == 0) + { + linesBuilder.Add(text); + } + else + { + linesBuilder[^1] = linesBuilder[^1] + text; + } + } + + public void AppendLine(string text = "") + { + linesBuilder.Add(text); + } + + public bool IsLineEmpty() + { + return linesBuilder is [] or [.., ""]; + } + + public string Build(string newLine) + { + return string.Join(newLine, linesBuilder); + } +} + +internal static class TextTagsInternal +{ + public const string ContainerStart = nameof(ContainerStart); + public const string ContainerEnd = nameof(ContainerEnd); + public const string CodeBlockStart = nameof(CodeBlockStart); + public const string CodeBlockEnd = nameof(CodeBlockEnd); +} diff --git a/src/RoslynWorkspaceAccess/RoslynWorkspaceAccess.csproj b/src/RoslynWorkspaceAccess/RoslynWorkspaceAccess.csproj index 9a425069..8dacfe9c 100644 --- a/src/RoslynWorkspaceAccess/RoslynWorkspaceAccess.csproj +++ b/src/RoslynWorkspaceAccess/RoslynWorkspaceAccess.csproj @@ -1,9 +1,14 @@  - Microsoft.CodeAnalysis.Workspaces.Test.Utilities + Microsoft.CodeAnalysis.Workspaces.UnitTests $(PkgMicrosoft_DotNet_Arcade_Sdk)\tools\snk\35MSSharedLib1024.snk true + + + $(NoWarn);CS8002 @@ -11,4 +16,8 @@ + + + + diff --git a/src/RoslynWorkspaceAccess/RoslynWorkspaceAccessors.cs b/src/RoslynWorkspaceAccess/RoslynWorkspaceAccessors.cs index e6bc2c81..27939487 100644 --- a/src/RoslynWorkspaceAccess/RoslynWorkspaceAccessors.cs +++ b/src/RoslynWorkspaceAccess/RoslynWorkspaceAccessors.cs @@ -1,7 +1,9 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.ExternalAccess.Pythia.Api; using Microsoft.CodeAnalysis.ExtractClass; using Microsoft.CodeAnalysis.ExtractInterface; using Microsoft.CodeAnalysis.GenerateType; @@ -12,6 +14,7 @@ using Microsoft.CodeAnalysis.Notification; using Microsoft.CodeAnalysis.ProjectManagement; using Microsoft.CodeAnalysis.Serialization; +using Microsoft.CodeAnalysis.SignatureHelp; using Microsoft.CodeAnalysis.Storage; using Microsoft.CodeAnalysis.Text; using System.Composition; @@ -20,6 +23,13 @@ namespace DotNetLab; public static class RoslynWorkspaceAccessors { + extension(TaggedText taggedText) + { + public TaggedTextStylePublic Style => (TaggedTextStylePublic)taggedText.Style; + public string? NavigationHint => taggedText.NavigationHint; + public string? NavigationTarget => taggedText.NavigationTarget; + } + public static async Task> GetCodeActionsAsync(this Document document, TextSpan span, CancellationToken cancellationToken) { var codeFixService = document.Project.Solution.Services.ExportProvider.GetExports().Single().Value; @@ -47,6 +57,71 @@ public static DocumentTextDifferencingService GetDocumentTextDifferencingService return new DocumentTextDifferencingService(service); } + public static async Task GetSignatureHelpAsync(this Document document, int position, SignatureHelpTriggerReasonPublic reason, char? triggerCharacter, CancellationToken cancellationToken) + { + var signatureHelpService = document.Project.Solution.Services.ExportProvider.GetExports().Single().Value; + var triggerInfo = new SignatureHelpTriggerInfo((SignatureHelpTriggerReason)reason, triggerCharacter); + var (_, bestItems) = await signatureHelpService.GetSignatureHelpAsync(document, position, triggerInfo, cancellationToken); + if (bestItems == null) + { + return null; + } + + return new SignatureHelp + { + ActiveSignature = getActiveSignature(bestItems), + ActiveParameter = bestItems.SemanticParameterIndex, + Signatures = bestItems.Items.SelectAsArray(i => new SignatureInformation + { + Label = getSignatureText(i), + Parameters = i.Parameters.SelectAsArray(static p => new ParameterInformation + { + Label = p.Name, + }), + ActiveParameter = bestItems.SemanticParameterIndex, + }), + }; + + static string getSignatureText(SignatureHelpItem item) + { + var sb = new StringBuilder(); + + sb.Append(item.PrefixDisplayParts.GetFullText()); + + var separators = item.SeparatorDisplayParts.GetFullText(); + for (var i = 0; i < item.Parameters.Length; i++) + { + var param = item.Parameters[i]; + + if (i > 0) + { + sb.Append(separators); + } + + sb.Append(param.PrefixDisplayParts.GetFullText()); + sb.Append(param.DisplayParts.GetFullText()); + sb.Append(param.SuffixDisplayParts.GetFullText()); + } + + sb.Append(item.SuffixDisplayParts.GetFullText()); + sb.Append(item.DescriptionParts.GetFullText()); + + return sb.ToString(); + } + + static int getActiveSignature(SignatureHelpItems items) + { + if (items.SelectedItemIndex.HasValue) + { + return items.SelectedItemIndex.Value; + } + + var matchingSignature = items.Items.FirstOrDefault( + sig => sig.Parameters.Length > items.SemanticParameterIndex); + return matchingSignature != null ? items.Items.IndexOf(matchingSignature) : 0; + } + } + [SuppressMessage("Interoperability", "CA1416: Validate platform compatibility")] public static AnalyzerImageReference RegisterAnalyzer(this AnalyzerImageReference analyzer) { @@ -126,3 +201,32 @@ GenerateTypeOptionsResult IGenerateTypeOptionsService.GetGenerateTypeOptions(str return GenerateTypeOptionsResult.Cancelled; } } + +[Export(typeof(IPythiaSignatureHelpProviderImplementation)), Shared] +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +public sealed class NoOpPythiaSignatureHelpProviderImplementation() : IPythiaSignatureHelpProviderImplementation +{ + Task<(ImmutableArray items, int? selectedItemIndex)> IPythiaSignatureHelpProviderImplementation.GetMethodGroupItemsAndSelectionAsync(ImmutableArray accessibleMethods, Document document, InvocationExpressionSyntax invocationExpression, SemanticModel semanticModel, SymbolInfo currentSymbol, CancellationToken cancellationToken) + { + return Task.FromResult<(ImmutableArray, int?)>(([], null)); + } +} + +[Flags] +public enum TaggedTextStylePublic +{ + None = 0, + Strong = 1 << 0, + Emphasis = 1 << 1, + Underline = 1 << 2, + Code = 1 << 3, + PreserveWhitespace = 1 << 4, +} + +public enum SignatureHelpTriggerReasonPublic +{ + InvokeSignatureHelpCommand, + TypeCharCommand, + RetriggerCommand, +} diff --git a/src/Shared/ILanguageServices.cs b/src/Shared/ILanguageServices.cs index dda7fcf4..b29aeed5 100644 --- a/src/Shared/ILanguageServices.cs +++ b/src/Shared/ILanguageServices.cs @@ -5,14 +5,16 @@ namespace DotNetLab; public interface ILanguageServices { - Task> GetDiagnosticsAsync(string modelUri); - void OnCompilationFinished(); - Task OnDidChangeModelContentAsync(string modelUri, ModelContentChangedEvent args); - Task OnDidChangeWorkspaceAsync(ImmutableArray models); - Task ProvideCodeActionsAsync(string modelUri, string? rangeJson, CancellationToken cancellationToken); Task ProvideCompletionItemsAsync(string modelUri, Position position, BlazorMonaco.Languages.CompletionContext context, CancellationToken cancellationToken); - Task ProvideSemanticTokensAsync(string modelUri, string? rangeJson, bool debug, CancellationToken cancellationToken); Task ResolveCompletionItemAsync(MonacoCompletionItem item, CancellationToken cancellationToken); + Task ProvideSemanticTokensAsync(string modelUri, string? rangeJson, bool debug, CancellationToken cancellationToken); + Task ProvideCodeActionsAsync(string modelUri, string? rangeJson, CancellationToken cancellationToken); + Task ProvideHoverAsync(string modelUri, string positionJson, CancellationToken cancellationToken); + Task ProvideSignatureHelpAsync(string modelUri, string positionJson, string contextJson, CancellationToken cancellationToken); + void OnCompilationFinished(); + Task OnDidChangeWorkspaceAsync(ImmutableArray models); + Task OnDidChangeModelContentAsync(string modelUri, ModelContentChangedEvent args); + Task> GetDiagnosticsAsync(string modelUri); } public sealed record ModelInfo(string Uri, string FileName) diff --git a/src/Shared/MonacoEditor.cs b/src/Shared/MonacoEditor.cs index 8a7e059b..28bf0b6e 100644 --- a/src/Shared/MonacoEditor.cs +++ b/src/Shared/MonacoEditor.cs @@ -71,6 +71,42 @@ public sealed class SemanticTokensLegend public ImmutableArray TokenModifiers { get; init; } } +/// +/// Monaco docs: . +/// +public sealed class SignatureHelp +{ + public required int ActiveParameter { get; init; } + public required int ActiveSignature { get; init; } + public required ImmutableArray Signatures { get; init; } +} + +public readonly struct SignatureInformation +{ + public required string Label { get; init; } + public int? ActiveParameter { get; init; } + public required ImmutableArray Parameters { get; init; } +} + +public readonly struct ParameterInformation +{ + public required string Label { get; init; } +} + +public sealed class SignatureHelpContext +{ + public bool IsRetrigger { get; init; } + public char? TriggerCharacter { get; init; } + public SignatureHelpTriggerKind TriggerKind { get; init; } +} + +public enum SignatureHelpTriggerKind +{ + Invoke = 1, + TriggerCharacter = 2, + ContentChange = 3, +} + [JsonSerializable(typeof(LanguageSelector))] [JsonSerializable(typeof(Position))] [JsonSerializable(typeof(CompletionContext))] @@ -78,6 +114,8 @@ public sealed class SemanticTokensLegend [JsonSerializable(typeof(MonacoCompletionList))] [JsonSerializable(typeof(ImmutableArray))] [JsonSerializable(typeof(SemanticTokensLegend))] +[JsonSerializable(typeof(SignatureHelp))] +[JsonSerializable(typeof(SignatureHelpContext))] [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] diff --git a/src/Shared/Util.cs b/src/Shared/Util.cs index 32f74bfb..35401463 100644 --- a/src/Shared/Util.cs +++ b/src/Shared/Util.cs @@ -166,6 +166,16 @@ public static async Task> SelectAsync(this IEnu return results; } + public static ImmutableArray SelectAsArray(this IEnumerable source, Func selector) + { + var results = ImmutableArray.CreateBuilder(source.TryGetNonEnumeratedCount(out var count) ? count : 0); + foreach (var item in source) + { + results.Add(selector(item)); + } + return results.DrainToImmutable(); + } + public static async Task> SelectAsArrayAsync(this IEnumerable source, Func> selector) { var results = ImmutableArray.CreateBuilder(source.TryGetNonEnumeratedCount(out var count) ? count : 0); diff --git a/src/Worker/Executor.cs b/src/Worker/Executor.cs index b0bb253d..5075f994 100644 --- a/src/Worker/Executor.cs +++ b/src/Worker/Executor.cs @@ -137,6 +137,22 @@ public async Task HandleAsync(WorkerInputMessage.ProvideCompletionItems return await languageServices.ProvideCodeActionsAsync(message.ModelUri, message.RangeJson, cancellationToken); } + public async Task HandleAsync(WorkerInputMessage.ProvideHover message) + { + using var _ = GetCancellationToken(message, out var cancellationToken); + var compiler = services.GetRequiredService(); + var languageServices = await compiler.GetLanguageServicesAsync(); + return await languageServices.ProvideHoverAsync(message.ModelUri, message.PositionJson, cancellationToken); + } + + public async Task HandleAsync(WorkerInputMessage.ProvideSignatureHelp message) + { + using var _ = GetCancellationToken(message, out var cancellationToken); + var compiler = services.GetRequiredService(); + var languageServices = await compiler.GetLanguageServicesAsync(); + return await languageServices.ProvideSignatureHelpAsync(message.ModelUri, message.PositionJson, message.ContextJson, cancellationToken); + } + public async Task HandleAsync(WorkerInputMessage.OnDidChangeWorkspace message) { var compiler = services.GetRequiredService(); diff --git a/src/Worker/Lab/CompilerProxy.cs b/src/Worker/Lab/CompilerProxy.cs index 9500021c..53dc6f83 100644 --- a/src/Worker/Lab/CompilerProxy.cs +++ b/src/Worker/Lab/CompilerProxy.cs @@ -133,7 +133,7 @@ private async Task> LoadAssembliesAs "Microsoft.CodeAnalysis.CSharp.Test.Utilities", // RoslynAccess project produces this assembly "Microsoft.CodeAnalysis.Razor.Test", // RazorAccess project produces this assembly "Microsoft.CodeAnalysis.CSharp.CodeStyle.UnitTests", // RoslynCodeStyleAccess project produces this assembly - "Microsoft.CodeAnalysis.Workspaces.Test.Utilities", // RoslynWorkspaceAccess project produces this assembly + "Microsoft.CodeAnalysis.Workspaces.UnitTests", // RoslynWorkspaceAccess project produces this assembly ]; foreach (var name in names) { diff --git a/src/WorkerApi/InputMessage.cs b/src/WorkerApi/InputMessage.cs index f8f44c2c..718a990c 100644 --- a/src/WorkerApi/InputMessage.cs +++ b/src/WorkerApi/InputMessage.cs @@ -17,6 +17,8 @@ namespace DotNetLab; [JsonDerivedType(typeof(ResolveCompletionItem), nameof(ResolveCompletionItem))] [JsonDerivedType(typeof(ProvideSemanticTokens), nameof(ProvideSemanticTokens))] [JsonDerivedType(typeof(ProvideCodeActions), nameof(ProvideCodeActions))] +[JsonDerivedType(typeof(ProvideHover), nameof(ProvideHover))] +[JsonDerivedType(typeof(ProvideSignatureHelp), nameof(ProvideSignatureHelp))] [JsonDerivedType(typeof(OnDidChangeWorkspace), nameof(OnDidChangeWorkspace))] [JsonDerivedType(typeof(OnDidChangeModelContent), nameof(OnDidChangeModelContent))] [JsonDerivedType(typeof(GetDiagnostics), nameof(GetDiagnostics))] @@ -134,6 +136,22 @@ public sealed record ProvideCodeActions(string ModelUri, string? RangeJson) : Wo } } + public sealed record ProvideHover(string ModelUri, string PositionJson) : WorkerInputMessage + { + public override Task HandleAsync(IExecutor executor) + { + return executor.HandleAsync(this); + } + } + + public sealed record ProvideSignatureHelp(string ModelUri, string PositionJson, string ContextJson) : WorkerInputMessage + { + public override Task HandleAsync(IExecutor executor) + { + return executor.HandleAsync(this); + } + } + public sealed record OnDidChangeWorkspace(ImmutableArray Models) : WorkerInputMessage { public override Task HandleAsync(IExecutor executor) @@ -171,6 +189,8 @@ public interface IExecutor Task HandleAsync(ResolveCompletionItem message); Task HandleAsync(ProvideSemanticTokens message); Task HandleAsync(ProvideCodeActions message); + Task HandleAsync(ProvideHover message); + Task HandleAsync(ProvideSignatureHelp message); Task HandleAsync(OnDidChangeWorkspace message); Task HandleAsync(OnDidChangeModelContent message); Task> HandleAsync(GetDiagnostics message); diff --git a/test/UnitTests/LanguageServiceTests.cs b/test/UnitTests/LanguageServiceTests.cs index 0402df3e..4ed54a31 100644 --- a/test/UnitTests/LanguageServiceTests.cs +++ b/test/UnitTests/LanguageServiceTests.cs @@ -1,4 +1,5 @@ using AwesomeAssertions; +using BlazorMonaco; using DotNetLab.Lab; using Microsoft.Extensions.DependencyInjection; using System.Text.Json; @@ -31,4 +32,26 @@ public async Task CodeActions(string code, string expectedErrorCode, string expe markers.Select(m => m.Code.ToString()).Should().ContainMatch($"*{expectedErrorCode}*"); codeActions.Select(c => c.Title).Should().Contain(expectedCodeActionTitle); } + + [Fact] + public async Task SignatureHelp() + { + var services = WorkerServices.CreateTest(); + var compiler = services.GetRequiredService(); + var languageServices = await compiler.GetLanguageServicesAsync(); + var code = """ + C.M(); + class C { public static void M(int x) { } } + """; + var file = "test.cs"; + await languageServices.OnDidChangeWorkspaceAsync([new(file, file) { NewContent = code }]); + + var positionJson = JsonSerializer.Serialize(new Position { LineNumber = 1, Column = 5 }, BlazorMonacoJsonContext.Default.Position); + var contextJson = JsonSerializer.Serialize(new SignatureHelpContext { TriggerKind = SignatureHelpTriggerKind.Invoke, IsRetrigger = false }, BlazorMonacoJsonContext.Default.SignatureHelpContext); + var signatureHelpJson = await languageServices.ProvideSignatureHelpAsync(file, positionJson, contextJson, TestContext.Current.CancellationToken); + var signatureHelp = JsonSerializer.Deserialize(signatureHelpJson!, BlazorMonacoJsonContext.Default.SignatureHelp); + + signatureHelp!.Signatures.Should().ContainSingle() + .Which.Label.Should().Be("void C.M(int x)"); + } }