From bb8a413d396bc7b45df9bc2128e9783c1ab7cdb1 Mon Sep 17 00:00:00 2001 From: Rekkonnect Date: Mon, 30 Jun 2025 23:57:03 +0200 Subject: [PATCH 1/4] Highlight keywords --- CSharpRepl/CSharpReplPromptCallbacks.cs | 30 ++++++++++++++++ CSharpRepl/ReadEvalPrintLoop.cs | 46 +++++++++++++++++-------- 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/CSharpRepl/CSharpReplPromptCallbacks.cs b/CSharpRepl/CSharpReplPromptCallbacks.cs index cf3ff56..54417c5 100644 --- a/CSharpRepl/CSharpReplPromptCallbacks.cs +++ b/CSharpRepl/CSharpReplPromptCallbacks.cs @@ -132,10 +132,40 @@ private static ImmutableArray MergeCommitRules( protected override async Task> HighlightCallbackAsync(string text, CancellationToken cancellationToken) { + var replKeywordSpan = HighlightReplKeyword(text); + if (replKeywordSpan is not null) + { + return [replKeywordSpan.Value]; + } + var classifications = await roslyn.SyntaxHighlightAsync(text).ConfigureAwait(false); return classifications.ToFormatSpans(); } + private static FormatSpan? HighlightReplKeyword(string text) + { + var trimmed = text.Trim().ToLowerInvariant(); + switch (trimmed) + { + case ReadEvalPrintLoop.Keywords.HelpText: + case "#help": + return FullSpanWithColor(ReadEvalPrintLoop.Keywords.HelpInfo.Color); + + case ReadEvalPrintLoop.Keywords.ExitText: + return FullSpanWithColor(ReadEvalPrintLoop.Keywords.ExitInfo.Color); + + case ReadEvalPrintLoop.Keywords.ClearText: + return FullSpanWithColor(ReadEvalPrintLoop.Keywords.ClearInfo.Color); + } + + return null; + + FormatSpan? FullSpanWithColor(AnsiColor color) + { + return new(0, text.Length, color); + } + } + protected override async Task TransformKeyPressAsync(string text, int caret, KeyPress keyPress, CancellationToken cancellationToken) { // user submitted the prompt but it's incomplete. Insert a newline automatically with the correct level of indentation. diff --git a/CSharpRepl/ReadEvalPrintLoop.cs b/CSharpRepl/ReadEvalPrintLoop.cs index 6a15b83..075f7d4 100644 --- a/CSharpRepl/ReadEvalPrintLoop.cs +++ b/CSharpRepl/ReadEvalPrintLoop.cs @@ -204,18 +204,36 @@ static string KeyPressPatternToString(IEnumerable patterns) } } - private static string Help => - PromptConfiguration.HasUserOptedOutFromColor - ? @"""help""" - : AnsiColor.Green.GetEscapeSequence() + "help" + AnsiEscapeCodes.Reset; - - private static string Exit => - PromptConfiguration.HasUserOptedOutFromColor - ? @"""exit""" - : AnsiColor.BrightRed.GetEscapeSequence() + "exit" + AnsiEscapeCodes.Reset; - - private static string Clear => - PromptConfiguration.HasUserOptedOutFromColor - ? @"""clear""" - : AnsiColor.BrightBlue.GetEscapeSequence() + "clear" + AnsiEscapeCodes.Reset; + public static string Help => Keywords.Help; + public static string Exit => Keywords.Exit; + public static string Clear => Keywords.Clear; + + public static class Keywords + { + public const string HelpText = "help"; + public const string ExitText = "exit"; + public const string ClearText = "clear"; + + public static readonly KeywordInfo HelpInfo = new(HelpText, AnsiColor.Green); + public static readonly KeywordInfo ExitInfo = new(ExitText, AnsiColor.BrightRed); + public static readonly KeywordInfo ClearInfo = new(ClearText, AnsiColor.BrightBlue); + + public static string Help => GetColoredText(HelpInfo); + public static string Exit => GetColoredText(ExitInfo); + public static string Clear => GetColoredText(ClearInfo); + + private static string GetColoredText(KeywordInfo keywordInfo) + { + return GetColoredText(keywordInfo.Text, keywordInfo.Color); + } + + private static string GetColoredText(string text, AnsiColor color) + { + return PromptConfiguration.HasUserOptedOutFromColor + ? $@"""{text}""" + : color.GetEscapeSequence() + text + AnsiEscapeCodes.Reset; + } + + public sealed record KeywordInfo(string Text, AnsiColor Color); + } } From cc0d6fb5f48428ef98b41b91820d1bae376a6d0c Mon Sep 17 00:00:00 2001 From: Rekkonnect Date: Tue, 1 Jul 2025 08:37:41 +0200 Subject: [PATCH 2/4] Add keyword completion items --- CSharpRepl/CSharpReplPromptCallbacks.cs | 75 +++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 6 deletions(-) diff --git a/CSharpRepl/CSharpReplPromptCallbacks.cs b/CSharpRepl/CSharpReplPromptCallbacks.cs index 54417c5..3b0bb11 100644 --- a/CSharpRepl/CSharpReplPromptCallbacks.cs +++ b/CSharpRepl/CSharpReplPromptCallbacks.cs @@ -3,6 +3,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. using System; +using System.Buffers; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; @@ -31,6 +32,9 @@ namespace CSharpRepl.PrettyPromptConfig; /// internal class CSharpReplPromptCallbacks : PromptCallbacks { + private const string lowercaseLetters = "abcdefghijklmnopqrstuvwxyz"; + private static SearchValues lowercaseSearchValues = SearchValues.Create(lowercaseLetters); + private readonly IConsoleEx console; private readonly RoslynServices roslyn; private readonly Configuration configuration; @@ -90,12 +94,35 @@ protected override Task ShouldOpenCompletionWindowAsync(string text, int c protected override async Task> GetCompletionItemsAsync(string text, int caret, TextSpan spanToBeReplaced, CancellationToken cancellationToken) { + var replKeywordCompletions = GetReplKeywordCompletions(); + var completions = await roslyn.CompleteAsync(text, caret).ConfigureAwait(false); - return completions - .OrderByDescending(i => i.Item.Rules.MatchPriority) - .ThenBy(i => i.Item.SortText) - .Select(CreatePrettyPromptCompletionItem) + return replKeywordCompletions + .Concat(completions + .OrderByDescending(i => i.Item.Rules.MatchPriority) + .ThenBy(i => i.Item.SortText) + .Select(CreatePrettyPromptCompletionItem)) .ToArray(); + + IEnumerable GetReplKeywordCompletions() + { + var trimmed = text.AsSpan().Trim(); + const int largestKeywordLength = 5; + if (trimmed.Length > largestKeywordLength) + { + return []; + } + + Span lowercaseBuffer = stackalloc char[largestKeywordLength]; + trimmed.ToLowerInvariant(lowercaseBuffer); + var lowercaseTrimmed = lowercaseBuffer.TrimEnd('\0'); + if (lowercaseTrimmed.ContainsAnyExcept(lowercaseSearchValues)) + { + return []; + } + + return ReplKeywordCompletionItems.AllItems; + } } internal CompletionItem CreatePrettyPromptCompletionItem(CompletionItemWithDescription r) @@ -160,9 +187,9 @@ protected override async Task> HighlightCallback return null; - FormatSpan? FullSpanWithColor(AnsiColor color) + FormatSpan FullSpanWithColor(AnsiColor color) { - return new(0, text.Length, color); + return EntireWordFormatSpan(text, color); } } @@ -283,6 +310,42 @@ protected override Task ConfirmCompletionCommit(string text, int caret, Ke return null; } + + private static FormatSpan EntireWordFormatSpan(ReadOnlySpan word, AnsiColor color) + { + return new(0, word.Length, color); + } + + private static FormattedString EntireWordFormatString(string word, AnsiColor color) + { + return new(word, EntireWordFormatSpan(word, color)); + } + + private static FormattedString EntireWordFormatString(ReadEvalPrintLoop.Keywords.KeywordInfo keywordInfo) + { + return EntireWordFormatString(keywordInfo.Text, keywordInfo.Color); + } + + private static class ReplKeywordCompletionItems + { + private static readonly FormattedString helpFormattedString = EntireWordFormatString(ReadEvalPrintLoop.Keywords.HelpInfo); + private static readonly FormattedString exitFormattedString = EntireWordFormatString(ReadEvalPrintLoop.Keywords.ExitInfo); + private static readonly FormattedString clearFormattedString = EntireWordFormatString(ReadEvalPrintLoop.Keywords.ClearInfo); + + public static CompletionItem Help { get; } = new( + ReadEvalPrintLoop.Keywords.HelpText, + displayText: helpFormattedString); + + public static CompletionItem Exit { get; } = new( + ReadEvalPrintLoop.Keywords.ExitText, + displayText: exitFormattedString); + + public static CompletionItem Clear { get; } = new( + ReadEvalPrintLoop.Keywords.ClearText, + displayText: clearFormattedString); + + public static IReadOnlyList AllItems = [Help, Exit, Clear]; + } } /// From d19c44bfd247fb33d34a2497f219eadf3bc725b1 Mon Sep 17 00:00:00 2001 From: Rekkonnect Date: Tue, 1 Jul 2025 08:47:37 +0200 Subject: [PATCH 3/4] Make completion cancellable --- CSharpRepl.Services/Completion/AutoCompleteService.cs | 5 +++-- CSharpRepl.Services/Roslyn/RoslynServices.cs | 4 ++-- CSharpRepl/CSharpReplPromptCallbacks.cs | 8 +++++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CSharpRepl.Services/Completion/AutoCompleteService.cs b/CSharpRepl.Services/Completion/AutoCompleteService.cs index b788f49..13af2fb 100644 --- a/CSharpRepl.Services/Completion/AutoCompleteService.cs +++ b/CSharpRepl.Services/Completion/AutoCompleteService.cs @@ -5,6 +5,7 @@ using System; using System.Diagnostics; using System.Linq; +using System.Threading; using System.Threading.Tasks; using CSharpRepl.Services.Extensions; using CSharpRepl.Services.SyntaxHighlighting; @@ -35,7 +36,7 @@ public AutoCompleteService(SyntaxHighlighter highlighter, IMemoryCache cache, Co this.configuration = configuration; } - public async Task Complete(Document document, string text, int caret) + public async Task Complete(Document document, string text, int caret, CancellationToken cancellationToken) { var cacheKey = CacheKeyPrefix + document.Name + text + caret; if (text != string.Empty && cache.Get(cacheKey) is CompletionItemWithDescription[] cached) @@ -47,7 +48,7 @@ public async Task Complete(Document document, s try { var completions = await completionService - .GetCompletionsAsync(document, caret) + .GetCompletionsAsync(document, caret, cancellationToken: cancellationToken) .ConfigureAwait(false); var completionsWithDescriptions = completions?.ItemsList diff --git a/CSharpRepl.Services/Roslyn/RoslynServices.cs b/CSharpRepl.Services/Roslyn/RoslynServices.cs index 0fde942..4b480f3 100644 --- a/CSharpRepl.Services/Roslyn/RoslynServices.cs +++ b/CSharpRepl.Services/Roslyn/RoslynServices.cs @@ -192,13 +192,13 @@ public async Task> GetPreviousSubmissionsAsync() .ToList(); } - public async Task> CompleteAsync(string text, int caret) + public async Task> CompleteAsync(string text, int caret, CancellationToken cancellationToken = default) { if (!Initialization.IsCompleted) return []; var document = workspaceManager.CurrentDocument.WithText(SourceText.From(text)); - return await autocompleteService.Complete(document, text, caret).ConfigureAwait(false); + return await autocompleteService.Complete(document, text, caret, cancellationToken).ConfigureAwait(false); } public async Task GetSymbolAtIndexAsync(string text, int caret) diff --git a/CSharpRepl/CSharpReplPromptCallbacks.cs b/CSharpRepl/CSharpReplPromptCallbacks.cs index 3b0bb11..53df3d8 100644 --- a/CSharpRepl/CSharpReplPromptCallbacks.cs +++ b/CSharpRepl/CSharpReplPromptCallbacks.cs @@ -93,10 +93,16 @@ protected override Task ShouldOpenCompletionWindowAsync(string text, int c => roslyn.ShouldOpenCompletionWindowAsync(text, caret, keyPress, cancellationToken); protected override async Task> GetCompletionItemsAsync(string text, int caret, TextSpan spanToBeReplaced, CancellationToken cancellationToken) + { + return await GetCompletionItemsCoreAsync(text, caret, cancellationToken).ConfigureAwait(false); + } + + // Made internal for testing + internal async Task> GetCompletionItemsCoreAsync(string text, int caret, CancellationToken cancellationToken = default) { var replKeywordCompletions = GetReplKeywordCompletions(); - var completions = await roslyn.CompleteAsync(text, caret).ConfigureAwait(false); + var completions = await roslyn.CompleteAsync(text, caret, cancellationToken).ConfigureAwait(false); return replKeywordCompletions .Concat(completions .OrderByDescending(i => i.Item.Rules.MatchPriority) From 0d7f0b87463feed243473a57939a6947fa0637b7 Mon Sep 17 00:00:00 2001 From: Rekkonnect Date: Tue, 1 Jul 2025 08:47:46 +0200 Subject: [PATCH 4/4] Add tests for REPL keyword completion --- CSharpRepl.Tests/CompletionTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CSharpRepl.Tests/CompletionTests.cs b/CSharpRepl.Tests/CompletionTests.cs index 75db758..da350aa 100644 --- a/CSharpRepl.Tests/CompletionTests.cs +++ b/CSharpRepl.Tests/CompletionTests.cs @@ -126,4 +126,15 @@ public async Task Complete_ItemsFilteringAndOrder(string text, params string[] e Assert.Equal(expectedItems[i], completions.ElementAt(i).Item.DisplayText); } } + + [Theory] + [InlineData("he", "help")] + [InlineData("ex", "exit")] + [InlineData("cl", "clear")] + public async Task Complete_ReplKeywords(string source, string item) + { + var completions = await promptCallbacks.GetCompletionItemsCoreAsync(source, source.Length); + var completion = completions.SingleOrDefault(c => c.DisplayText == item); + Assert.NotNull(completion); + } } \ No newline at end of file