Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 8 additions & 37 deletions CSharpRepl.Services/Completion/AutoCompleteService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<FormatSpan>(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<char> 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<FormattedString> GetExtendedDescriptionAsync(CompletionService completionService, Document document, CompletionItem item, SyntaxHighlighter highlighter)
{
var description = await completionService.GetDescriptionAsync(document, item);
Expand Down
57 changes: 57 additions & 0 deletions CSharpRepl.Services/Completion/CompletionItemSymbols.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The glyph shown before a completion/member name and the terminal-palette color it should be
/// drawn in (<see langword="null"/> = default/uncolored).
/// </summary>
public readonly record struct CompletionItemSymbol(string Prefix, int GlyphLength, ConsoleColor? Color);

/// <summary>
/// Maps a Roslyn classification to the leading glyph (and its color) shown for that kind.
/// </summary>
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;
}
14 changes: 12 additions & 2 deletions CSharpRepl.Services/Roslyn/Formatting/PrettyPrinter.Members.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<StyledStringSegment>(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)
Expand Down
2 changes: 1 addition & 1 deletion CSharpRepl.Services/Theming/FormattedStringParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 28 additions & 23 deletions CSharpRepl/CSharpReplPromptCallbacks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -38,6 +39,9 @@ internal class CSharpReplPromptCallbacks(IConsoleService console, RoslynServices
private static SearchValues<char> 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<CompletionItem> 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<string, KeyPressCallbackResult?> launchBrowser = launchBrowser ?? LaunchBrowser;

Expand Down Expand Up @@ -140,7 +144,7 @@ IEnumerable<CompletionItem> GetReplKeywordCompletions()
return [];
}

return ReplKeywordCompletionItems.AllItems;
return replKeywordCompletionItems;
}
}

Expand Down Expand Up @@ -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));
}

/// <summary>
Expand Down Expand Up @@ -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<CompletionItem> 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<string> 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<CompletionItem> AllItems = [Help, Exit, Clear];

private static readonly HashSet<string> replacementTexts =
AllItems.Select(i => i.ReplacementText).ToHashSet(StringComparer.OrdinalIgnoreCase);
};

public static bool IsFullyTypedKeyword(string text) => replacementTexts.Contains(text.Trim());
}
Expand Down
23 changes: 23 additions & 0 deletions Tests/CSharpRepl.Tests/ObjectFormatting/PrettyPrinterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions Tests/CSharpRepl.Tests/UnicodeCompletionMenuTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Exercises the autocomplete menu with <c>--useUnicode</c> 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.
/// </summary>
[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);
}
}
Loading