diff --git a/CSharpRepl.Services/Completion/AutoCompleteService.cs b/CSharpRepl.Services/Completion/AutoCompleteService.cs index 13af2fba..ccef8f4b 100644 --- a/CSharpRepl.Services/Completion/AutoCompleteService.cs +++ b/CSharpRepl.Services/Completion/AutoCompleteService.cs @@ -3,14 +3,14 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. using System; -using System.Diagnostics; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using CSharpRepl.Services.Extensions; using CSharpRepl.Services.SyntaxHighlighting; +using CSharpRepl.Services.Theming; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Classification; using Microsoft.CodeAnalysis.Completion; using Microsoft.Extensions.Caching.Memory; using PrettyPrompt.Highlighting; @@ -73,47 +73,18 @@ FormattedString GetDisplayText(CompletionItem item) var classification = RoslynExtensions.TextTagToClassificationTypeName(item.Tags.First()); if (highlighter.TryGetFormat(classification, out var format)) { - var prefix = GetCompletionItemSymbolPrefix(classification, configuration.UseUnicode); - return new FormattedString($"{prefix}{text}", new FormatSpan(prefix.Length, text.Length, format)); + var symbol = CompletionItemSymbols.Get(classification, configuration.UseUnicode); + var spans = new List(2); + if (CompletionItemSymbols.GetIconAnsiColor(symbol.Color) is { } iconColor) + spans.Add(new FormatSpan(0, symbol.GlyphLength, new ConsoleFormat(Foreground: iconColor))); + spans.Add(new FormatSpan(symbol.Prefix.Length, text.Length, format)); + return new FormattedString($"{symbol.Prefix}{text}", spans); } } return text; } } - public static string GetCompletionItemSymbolPrefix(string? classification, bool useUnicode) - { - Span prefix = stackalloc char[3]; - if (useUnicode) - { - var symbol = classification switch - { - ClassificationTypeNames.Keyword => "🔑", - ClassificationTypeNames.MethodName or ClassificationTypeNames.ExtensionMethodName => "🟣", - ClassificationTypeNames.PropertyName => "🟡", - ClassificationTypeNames.FieldName or ClassificationTypeNames.ConstantName or ClassificationTypeNames.EnumMemberName => "🔵", - ClassificationTypeNames.EventName => "⚡", - ClassificationTypeNames.ClassName or ClassificationTypeNames.RecordClassName => "🟨", - ClassificationTypeNames.InterfaceName => "🔷", - ClassificationTypeNames.StructName or ClassificationTypeNames.RecordStructName => "🟦", - ClassificationTypeNames.EnumName => "🟧", - ClassificationTypeNames.DelegateName => "💼", - ClassificationTypeNames.NamespaceName => "⬜", - ClassificationTypeNames.TypeParameterName => "⬛", - _ => "⚫", - }; - Debug.Assert(symbol.Length <= prefix.Length); - symbol.CopyTo(prefix); - prefix[symbol.Length] = ' '; - prefix = prefix[..(symbol.Length + 1)]; - return prefix.ToString(); - } - else - { - return ""; - } - } - private static async Task GetExtendedDescriptionAsync(CompletionService completionService, Document document, CompletionItem item, SyntaxHighlighter highlighter) { var description = await completionService.GetDescriptionAsync(document, item); diff --git a/CSharpRepl.Services/Completion/CompletionItemSymbols.cs b/CSharpRepl.Services/Completion/CompletionItemSymbols.cs new file mode 100644 index 00000000..9d309ad9 --- /dev/null +++ b/CSharpRepl.Services/Completion/CompletionItemSymbols.cs @@ -0,0 +1,57 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +using System; +using CSharpRepl.Services.Theming; +using Microsoft.CodeAnalysis.Classification; +using PrettyPrompt; +using PrettyPrompt.Highlighting; +using Spectre.Console; + +namespace CSharpRepl.Services.Completion; + +/// +/// The glyph shown before a completion/member name and the terminal-palette color it should be +/// drawn in ( = default/uncolored). +/// +public readonly record struct CompletionItemSymbol(string Prefix, int GlyphLength, ConsoleColor? Color); + +/// +/// Maps a Roslyn classification to the leading glyph (and its color) shown for that kind. +/// +public static class CompletionItemSymbols +{ + public static CompletionItemSymbol Get(string? classification, bool useUnicode) + { + if (!useUnicode) + return new CompletionItemSymbol("", 0, null); + + // Mnemonic circled letters (the kind's initial) so it's more clear what is a method, property, field, etc + (string Glyph, ConsoleColor? Color) symbol = classification switch + { + ClassificationTypeNames.Keyword => ("Ⓚ", null), + ClassificationTypeNames.MethodName or ClassificationTypeNames.ExtensionMethodName => ("Ⓜ", ConsoleColor.DarkBlue), + ClassificationTypeNames.PropertyName => ("Ⓟ", null), + ClassificationTypeNames.FieldName or ClassificationTypeNames.ConstantName or ClassificationTypeNames.EnumMemberName => ("Ⓕ", ConsoleColor.Cyan), + ClassificationTypeNames.EventName => ("↯", ConsoleColor.Yellow), + ClassificationTypeNames.ClassName or ClassificationTypeNames.RecordClassName => ("Ⓒ", null), + ClassificationTypeNames.InterfaceName => ("Ⓘ", null), + ClassificationTypeNames.StructName or ClassificationTypeNames.RecordStructName => ("Ⓢ", null), + ClassificationTypeNames.EnumName => ("Ⓔ", null), + ClassificationTypeNames.DelegateName => ("Ⓓ", null), + ClassificationTypeNames.NamespaceName => ("Ⓝ", null), + ClassificationTypeNames.TypeParameterName => ("Ⓣ", null), + _ => ("•", null), + }; + + // glyph + two padding spaces for clear separation from the name. + return new CompletionItemSymbol(symbol.Glyph + " ", symbol.Glyph.Length, symbol.Color); + } + + public static AnsiColor? GetIconAnsiColor(ConsoleColor? color) + => color is not null && !PromptConfiguration.HasUserOptedOutFromColor ? FormattedStringParser.FromConsoleColor(color.Value) : null; + + public static Color? GetIconSpectreColor(ConsoleColor? color) + => color is not null && !PromptConfiguration.HasUserOptedOutFromColor ? Color.FromConsoleColor(color.Value) : null; +} diff --git a/CSharpRepl.Services/Roslyn/Formatting/PrettyPrinter.Members.cs b/CSharpRepl.Services/Roslyn/Formatting/PrettyPrinter.Members.cs index 97c97be5..875a63c6 100644 --- a/CSharpRepl.Services/Roslyn/Formatting/PrettyPrinter.Members.cs +++ b/CSharpRepl.Services/Roslyn/Formatting/PrettyPrinter.Members.cs @@ -12,6 +12,7 @@ using CSharpRepl.Services.Roslyn.Formatting.Rendering; using CSharpRepl.Services.Theming; using Microsoft.CodeAnalysis.Scripting.Hosting; +using Spectre.Console; using Spectre.Console.Rendering; namespace CSharpRepl.Services.Roslyn.Formatting; @@ -64,9 +65,18 @@ private FormattedObject FormatMember(object memberParentValue, MemberInfo member private StyledString GetMemberDefaultName(MemberInfo member) { var classification = RoslynExtensions.MemberTypeToClassificationTypeName(member.MemberType); - var prefix = AutoCompleteService.GetCompletionItemSymbolPrefix(classification, config.UseUnicode); + var symbol = CompletionItemSymbols.Get(classification, config.UseUnicode); var style = syntaxHighlighter.GetStyle(classification); - return new StyledString([prefix, new StyledStringSegment(member.Name, style)]); + + var segments = new List(3); + if (symbol.Prefix.Length > 0) + { + Style? iconStyle = CompletionItemSymbols.GetIconSpectreColor(symbol.Color) is { } iconColor ? new Style(foreground: iconColor) : (Style?)null; + segments.Add(new StyledStringSegment(symbol.Prefix[..symbol.GlyphLength], iconStyle)); + segments.Add(new StyledStringSegment(symbol.Prefix[symbol.GlyphLength..])); + } + segments.Add(new StyledStringSegment(member.Name, style)); + return new StyledString(segments); } private IEnumerable<(object MemberParentValue, MemberInfo MemberInfo)> EnumerateMembers(object obj, bool includeNonPublic) diff --git a/CSharpRepl.Services/Theming/FormattedStringParser.cs b/CSharpRepl.Services/Theming/FormattedStringParser.cs index e62fbc29..3a020973 100644 --- a/CSharpRepl.Services/Theming/FormattedStringParser.cs +++ b/CSharpRepl.Services/Theming/FormattedStringParser.cs @@ -90,7 +90,7 @@ private static ConsoleFormat ToConsoleFormat(Style style) // Inverse of ThemeColor.TryConvertAnsiColorToConsoleColor: ConsoleColor's dark variants are the // base palette colors, the bright variants are the "Bright*" palette colors. - private static AnsiColor FromConsoleColor(ConsoleColor color) => color switch + internal static AnsiColor FromConsoleColor(ConsoleColor color) => color switch { ConsoleColor.Black => AnsiColor.Black, ConsoleColor.DarkRed => AnsiColor.Red, diff --git a/CSharpRepl/CSharpReplPromptCallbacks.cs b/CSharpRepl/CSharpReplPromptCallbacks.cs index 92891abc..33527f73 100644 --- a/CSharpRepl/CSharpReplPromptCallbacks.cs +++ b/CSharpRepl/CSharpReplPromptCallbacks.cs @@ -19,6 +19,7 @@ using CSharpRepl.Services.Roslyn.Scripting; using CSharpRepl.Services.SymbolExploration; using CSharpRepl.Services.SyntaxHighlighting; +using Microsoft.CodeAnalysis.Classification; using PrettyPrompt; using PrettyPrompt.Completion; using PrettyPrompt.Consoles; @@ -38,6 +39,9 @@ internal class CSharpReplPromptCallbacks(IConsoleService console, RoslynServices private static SearchValues lowercaseSearchValues = SearchValues.Create(lowercaseLetters); private readonly AICompleteService aiComplete = new AICompleteService(configuration.AICompletionConfiguration); + // Built once per session because the keyword glyph depends on configuration.UseUnicode, which is fixed. + private readonly IReadOnlyCollection replKeywordCompletionItems = ReplKeywordCompletionItems.Build(configuration.UseUnicode); + // How the F1/Ctrl+F1/F12 keybindings open a URL. Defaults to actually launching the browser; tests inject a no-op. private readonly Func launchBrowser = launchBrowser ?? LaunchBrowser; @@ -140,7 +144,7 @@ IEnumerable GetReplKeywordCompletions() return []; } - return ReplKeywordCompletionItems.AllItems; + return replKeywordCompletionItems; } } @@ -422,9 +426,12 @@ private static FormattedString EntireWordFormatString(string word, AnsiColor col return new(word, EntireWordFormatSpan(word, color)); } - private static FormattedString EntireWordFormatString(ReadEvalPrintLoop.Keywords.KeywordInfo keywordInfo) + // Like EntireWordFormatString, but prepends the keyword kind glyph (uncolored) so REPL commands + // line up with the keyword completions in the menu. The glyph is empty when not using unicode. + private static FormattedString KeywordCommandFormatString(ReadEvalPrintLoop.Keywords.KeywordInfo keywordInfo, bool useUnicode) { - return EntireWordFormatString(keywordInfo.Text, keywordInfo.Color); + var prefix = CompletionItemSymbols.Get(ClassificationTypeNames.Keyword, useUnicode).Prefix; + return new FormattedString(prefix + keywordInfo.Text, new FormatSpan(prefix.Length, keywordInfo.Text.Length, keywordInfo.Color)); } /// @@ -492,29 +499,27 @@ private static class InspectorCommandCompletionItems 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( + // help/exit/clear are REPL commands rather than C# keywords, but we show them with the keyword + // glyph so they read as keywords in the completion menu. The word keeps its own color; the + // glyph stays uncolored (matching how the keyword classification is tinted). + public static IReadOnlyCollection Build(bool useUnicode) => + [ + Create(ReadEvalPrintLoop.Keywords.HelpInfo, "Show help and usage information for the C# REPL.", useUnicode), + Create(ReadEvalPrintLoop.Keywords.ExitInfo, "Exit the REPL. You can also press Ctrl + d.", useUnicode), + Create(ReadEvalPrintLoop.Keywords.ClearInfo, "Clear the terminal screen.", useUnicode), + ]; + + private static CompletionItem Create(ReadEvalPrintLoop.Keywords.KeywordInfo info, string description, bool useUnicode) => new( + info.Text, + displayText: KeywordCommandFormatString(info, useUnicode), + getExtendedDescription: _ => Task.FromResult(new FormattedString(description))); + + private static readonly HashSet replacementTexts = new(StringComparer.OrdinalIgnoreCase) + { ReadEvalPrintLoop.Keywords.HelpText, - displayText: helpFormattedString, - getExtendedDescription: _ => Task.FromResult(new FormattedString("Show help and usage information for the C# REPL."))); - - public static CompletionItem Exit { get; } = new( ReadEvalPrintLoop.Keywords.ExitText, - displayText: exitFormattedString, - getExtendedDescription: _ => Task.FromResult(new FormattedString("Exit the REPL. You can also press Ctrl + d."))); - - public static CompletionItem Clear { get; } = new( ReadEvalPrintLoop.Keywords.ClearText, - displayText: clearFormattedString, - getExtendedDescription: _ => Task.FromResult(new FormattedString("Clear the terminal screen."))); - - public static IReadOnlyCollection AllItems = [Help, Exit, Clear]; - - private static readonly HashSet replacementTexts = - AllItems.Select(i => i.ReplacementText).ToHashSet(StringComparer.OrdinalIgnoreCase); + }; public static bool IsFullyTypedKeyword(string text) => replacementTexts.Contains(text.Trim()); } diff --git a/Tests/CSharpRepl.Tests/ObjectFormatting/PrettyPrinterTests.cs b/Tests/CSharpRepl.Tests/ObjectFormatting/PrettyPrinterTests.cs index be72d936..083808f3 100644 --- a/Tests/CSharpRepl.Tests/ObjectFormatting/PrettyPrinterTests.cs +++ b/Tests/CSharpRepl.Tests/ObjectFormatting/PrettyPrinterTests.cs @@ -255,6 +255,29 @@ public void TestObjectMembersFormatting(object obj, Level level, string[] expect } } + [Fact] + public void FormatMembers_WithUnicode_PrependsKindGlyphToEachMemberName() + { + // The object inspector shares the glyph logic with the completion menu (CompletionItemSymbols), + // reached here through PrettyPrinter.Members.GetMemberDefaultName → StyledString. This guards + // that interaction: with unicode on, member names gain the kind glyph + padding, aligned the + // same way the non-unicode TestObjectMembersFormatting expects without it. + var unicodePrettyPrinter = new PrettyPrinter( + console.AnsiConsole.Profile, + new SyntaxHighlighter( + new MemoryCache(new MemoryCacheOptions()), + new Theme(null, null, null, null, [])), + new Configuration(useUnicode: true)); + + var outputs = unicodePrettyPrinter + .FormatMembers(new TestClassWithMembers(), Level.FirstDetailed, includeNonPublic: true) + .Select(o => ToString(o.Renderable)) + .ToArray(); + + Assert.Contains("Ⓕ FieldInt32: 2", outputs); // field kind glyph + Assert.Contains("Ⓟ PropertyString: \"abcd\"", outputs); // property kind glyph + } + private class TestClassWithMembers { #pragma warning disable IDE0051, IDE0052 // Remove unread private members diff --git a/Tests/CSharpRepl.Tests/UnicodeCompletionMenuTests.cs b/Tests/CSharpRepl.Tests/UnicodeCompletionMenuTests.cs new file mode 100644 index 00000000..809d6b17 --- /dev/null +++ b/Tests/CSharpRepl.Tests/UnicodeCompletionMenuTests.cs @@ -0,0 +1,76 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +using System.Linq; +using System.Threading.Tasks; +using CSharpRepl.PrettyPromptConfig; +using CSharpRepl.Services; +using CSharpRepl.Services.Roslyn; +using Xunit; + +namespace CSharpRepl.Tests; + +/// +/// Exercises the autocomplete menu with --useUnicode turned on, end-to-end through the real +/// completion pipeline. Both kinds of menu entry pick up a leading kind-glyph, but via different +/// paths that must agree on the prefix layout: C# completions flow Roslyn → AutoCompleteService → +/// CompletionItemSymbols, while the help/exit/clear commands are glyphed in CSharpReplPromptCallbacks. +/// The bug-prone seam is offset alignment — if the glyph/padding length and the format-span offsets +/// disagree, the name's highlighting drifts — so we assert both the visible text and the spans. +/// +[Collection(nameof(RoslynServices))] +public sealed class UnicodeCompletionMenuTests : IAsyncLifetime +{ + private readonly RoslynServices services; + private readonly CSharpReplPromptCallbacks promptCallbacks; + + public UnicodeCompletionMenuTests() + { + var (console, _) = FakeConsole.CreateStubbedOutput(); + var config = new Configuration(useUnicode: true); + services = new RoslynServices(console, config, new TestTraceLogger()); + promptCallbacks = new CSharpReplPromptCallbacks(console, services, config); + } + + public ValueTask InitializeAsync() => new(services.WarmUpAsync([])); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + [Fact] + public async Task CompletionMenu_CSharpMethod_ShowsTintedGlyphWithAlignedName() + { + const string code = "Console.Writ"; + var completions = await promptCallbacks.GetCompletionItemsCoreAsync(code, code.Length, TestContext.Current.CancellationToken); + + var display = completions.First(c => c.ReplacementText == "WriteLine").DisplayTextFormatted; + var spans = display.FormatSpans.ToArray(); + + // glyph + two spaces + the original name, with nothing mangled in between. + Assert.Equal("Ⓜ WriteLine", display.Text); + + // The method glyph is tinted: a format span covers exactly the one-char glyph at the start. + Assert.Contains(spans, s => s.Start == 0 && s.Length == 1); + + // The name's syntax highlighting begins exactly after the "Ⓜ " prefix — i.e. the offset the + // span builder used matches the prefix length the glyph builder produced. + Assert.Contains(spans, s => s.Start == "Ⓜ ".Length && s.Length == "WriteLine".Length); + } + + [Fact] + public async Task CompletionMenu_ReplCommand_ShowsUncoloredKeywordGlyph() + { + var completions = await promptCallbacks.GetCompletionItemsCoreAsync("he", 2, TestContext.Current.CancellationToken); + + var display = completions.First(c => c.ReplacementText == "help").DisplayTextFormatted; + var spans = display.FormatSpans.ToArray(); + + // help/exit/clear are treated as keywords: keyword glyph, then the command word. + Assert.Equal("Ⓚ help", display.Text); + + // The keyword glyph stays uncolored (no span at the glyph)... + Assert.DoesNotContain(spans, s => s.Start == 0); + + // ...while the command word keeps its own color, offset past the "Ⓚ " prefix. + Assert.Contains(spans, s => s.Start == "Ⓚ ".Length && s.Length == "help".Length); + } +}