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
75 changes: 75 additions & 0 deletions CosmosDBShell.Tests/Shell/HighlighterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,81 @@ public void TestInterpolatedStringHighlight()
Assert.Equal("echo", segs[0].Text.Trim());
}

[Fact]
public void TestInterpolatedExpressionDoesNotDuplicateText()
{
// Regression: nested expressions inside $(...) carry positions from a separate
// sub-Lexer, so recursing into them while indexing this.text used to smear
// characters from the start of the line into the rendered output. The full
// visible text must round-trip exactly through the highlighter.
var input = "echo \"$(3+5)\"\"$(3+5)\"";
var highlighter = (IHighlighter)ShellInterpreter.Instance;

var res = highlighter.BuildHighlightedText(input) as Markup;
Assert.NotNull(res);
var rendered = string.Concat(res.GetSegments(AnsiConsole.Console).Select(s => s.Text));

Assert.Equal(input, rendered);
}

[Fact]
public void TestInterpolatedExpressionContentsAreColoredAsExpression()
{
// The '+' inside $( ... ) should be rendered with the operator color rather
// than being merged into the surrounding string-literal coloring. Spectre
// collapses adjacent segments that share a style, so any character whose
// color does not match the surrounding literal color must end up on its own
// segment — that is exactly what we want to verify.
var highlighter = (IHighlighter)ShellInterpreter.Instance;

var res = highlighter.BuildHighlightedText("echo \"$(3+5)\"") as Markup;
Assert.NotNull(res);
var segs = res.GetSegments(AnsiConsole.Console).ToList();

var plusSeg = segs.FirstOrDefault(s => s.Text == "+");
Assert.NotNull(plusSeg);

var quotedSeg = segs.FirstOrDefault(s => s.Text.Contains("\""));
Assert.NotNull(quotedSeg);
Assert.NotEqual(quotedSeg.Style.Foreground, plusSeg.Style.Foreground);
}

[Fact]
public void TestInterpolatedVariableIsColoredSeparately()
{
// $name inside an interpolated string should be rendered as a variable
// reference, not lumped together with the quoted text.
var highlighter = (IHighlighter)ShellInterpreter.Instance;

var input = "echo \"Hello $name!\"";
var res = highlighter.BuildHighlightedText(input) as Markup;
Assert.NotNull(res);

var rendered = string.Concat(res.GetSegments(AnsiConsole.Console).Select(s => s.Text));
Assert.Equal(input, rendered);

var segs = res.GetSegments(AnsiConsole.Console).ToList();
Assert.Contains(segs, s => s.Text == "$name");
}

[Fact]
public void TestInterpolatedExpressionWithEscapesRoundTrips()
{
// The cooked content of an interpolated string collapses escape sequences
// (e.g. \" -> "), so an inner Lexer that walks the cooked text would emit
// token positions that drift relative to the outer source. Verify that an
// interpolation containing an inner string literal with a backslash escape
// still renders the visible characters in their original positions.
var highlighter = (IHighlighter)ShellInterpreter.Instance;

var input = "echo \"$( \\\"a\\nb\\\" )\"";
var res = highlighter.BuildHighlightedText(input) as Markup;
Assert.NotNull(res);

var rendered = string.Concat(res.GetSegments(AnsiConsole.Console).Select(s => s.Text));
Assert.Equal(input, rendered);
}

[Fact]
public void TestExpressionHighlight()
{
Expand Down
90 changes: 90 additions & 0 deletions CosmosDBShell.Tests/UtilTest/JsonOutputHighlighterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------

using System.Text.Json;

using Azure.Data.Cosmos.Shell.Core;

namespace CosmosShell.Tests.UtilTest;

public class JsonOutputHighlighterTests
{
[Fact]
public void Primitives_AreColoredByType()
{
var element = JsonSerializer.Deserialize<JsonElement>("{ \"name\": \"alice\", \"age\": 42, \"active\": true, \"nick\": null }");

var markup = JsonOutputHighlighter.BuildMarkup(element);

// Property name uses the JSON property color (cyan).
Assert.Contains("[cyan]\"name\"[/]", markup);

// Each value type uses its dedicated helper from Theme.
Assert.Contains("[violet]\"alice\"[/]", markup);
Assert.Contains("[violet]42[/]", markup);
Assert.Contains("[violet]true[/]", markup);
Assert.Contains("[violet]null[/]", markup);

// Outer braces use the depth-0 bracket color; comma and colon use the
// shared punctuation color.
var depth0 = Theme.GetBracketColor(0);
Assert.Contains($"[{depth0}]{{[/]", markup);
Assert.Contains($"[{depth0}]}}[/]", markup);
Assert.Contains("[yellow]:[/]", markup);
Assert.Contains("[yellow],[/]", markup);
}

[Fact]
public void NestedObjectsAndArrays_AreIndented()
{
var element = JsonSerializer.Deserialize<JsonElement>("{ \"items\": [1, 2] }");

var markup = JsonOutputHighlighter.BuildMarkup(element);

// Two-space indentation matching Utf8JsonWriter(Indented=true).
Assert.Contains("\n [cyan]\"items\"[/]", markup);
Assert.Contains("\n [violet]1[/]", markup);
Assert.Contains("\n [violet]2[/]", markup);
}

[Fact]
public void EmptyObjectAndArray_RenderInline()
{
var emptyObject = JsonSerializer.Deserialize<JsonElement>("{}");
var emptyArray = JsonSerializer.Deserialize<JsonElement>("[]");

var depth0 = Theme.GetBracketColor(0);
Assert.Equal($"[{depth0}]{{[/][{depth0}]}}[/]", JsonOutputHighlighter.BuildMarkup(emptyObject));
Assert.Equal($"[{depth0}][[[/][{depth0}]]][/]", JsonOutputHighlighter.BuildMarkup(emptyArray));
}

[Fact]
public void StringValues_AreJsonAndMarkupEscaped()
{
var element = JsonSerializer.Deserialize<JsonElement>("{ \"q\": \"a\\\"b\" }");

var markup = JsonOutputHighlighter.BuildMarkup(element);

// The embedded quote stays JSON-escaped inside the markup token.
Assert.Contains("[violet]\"a\\u0022b\"[/]", markup);
}

[Fact]
public void NestedBrackets_CycleColorsByDepth()
{
// Depth 0 -> '{', depth 1 -> '[', depth 2 -> '{' (next nested object).
var element = JsonSerializer.Deserialize<JsonElement>("{ \"a\": [ { \"b\": 1 } ] }");

var markup = JsonOutputHighlighter.BuildMarkup(element);

Assert.Contains($"[{Theme.GetBracketColor(0)}]{{[/]", markup);
Assert.Contains($"[{Theme.GetBracketColor(1)}][[[/]", markup);
Assert.Contains($"[{Theme.GetBracketColor(2)}]{{[/]", markup);

// Closing brackets should use the same color as their matching opener.
Assert.Contains($"[{Theme.GetBracketColor(2)}]}}[/]", markup);
Assert.Contains($"[{Theme.GetBracketColor(1)}]]][/]", markup);
Assert.Contains($"[{Theme.GetBracketColor(0)}]}}[/]", markup);
}
}
139 changes: 139 additions & 0 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Core/JsonOutputHighlighter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------
namespace Azure.Data.Cosmos.Shell.Core;

using System.Text;
using System.Text.Json;
using Spectre.Console;

/// <summary>
/// Produces a Spectre.Console markup string for a <see cref="JsonElement"/>, applying the
/// JSON colors defined in <see cref="Theme"/>. The resulting layout matches the indented
/// output produced by <see cref="System.Text.Json.Utf8JsonWriter"/> with <c>Indented = true</c>.
/// </summary>
internal static class JsonOutputHighlighter
{
private const int IndentSize = 2;

public static string BuildMarkup(JsonElement element)
{
var sb = new StringBuilder();
WriteValue(sb, element, indent: 0);
return sb.ToString();
}

private static void WriteValue(StringBuilder sb, JsonElement element, int indent)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
WriteObject(sb, element, indent);
break;
case JsonValueKind.Array:
WriteArray(sb, element, indent);
break;
case JsonValueKind.String:
sb.Append(Theme.FormatJsonString(EncodeJsonString(element.GetString() ?? string.Empty)));
break;
case JsonValueKind.Number:
sb.Append(Theme.FormatJsonNumber(element.GetRawText()));
break;
case JsonValueKind.True:
case JsonValueKind.False:
sb.Append(Theme.FormatJsonBoolean(element.GetRawText()));
break;
case JsonValueKind.Null:
sb.Append(Theme.FormatJsonNull("null"));
break;
default:
sb.Append(Markup.Escape(element.GetRawText()));
break;
}
}

private static void WriteObject(StringBuilder sb, JsonElement element, int indent)
{
var enumerator = element.EnumerateObject();
if (!enumerator.MoveNext())
{
sb.Append(Theme.FormatBracket("{", indent));
sb.Append(Theme.FormatBracket("}", indent));
return;
}

sb.Append(Theme.FormatBracket("{", indent));
sb.Append('\n');

var first = true;
do
{
if (!first)
{
sb.Append(Theme.FormatJsonBracket(","));
sb.Append('\n');
}

first = false;

AppendIndent(sb, indent + 1);
sb.Append(Theme.FormatJsonProperty(EncodeJsonString(enumerator.Current.Name)));
sb.Append(Theme.FormatJsonBracket(":"));
sb.Append(' ');
WriteValue(sb, enumerator.Current.Value, indent + 1);
}
while (enumerator.MoveNext());

sb.Append('\n');
AppendIndent(sb, indent);
sb.Append(Theme.FormatBracket("}", indent));
}

private static void WriteArray(StringBuilder sb, JsonElement element, int indent)
{
var enumerator = element.EnumerateArray();
if (!enumerator.MoveNext())
{
sb.Append(Theme.FormatBracket("[", indent));
sb.Append(Theme.FormatBracket("]", indent));
return;
}

sb.Append(Theme.FormatBracket("[", indent));
sb.Append('\n');

var first = true;
do
{
if (!first)
{
sb.Append(Theme.FormatJsonBracket(","));
sb.Append('\n');
}

first = false;

AppendIndent(sb, indent + 1);
WriteValue(sb, enumerator.Current, indent + 1);
}
while (enumerator.MoveNext());

sb.Append('\n');
AppendIndent(sb, indent);
sb.Append(Theme.FormatBracket("]", indent));
}

private static void AppendIndent(StringBuilder sb, int level)
{
sb.Append(' ', level * IndentSize);
}

/// <summary>
/// Serializes the value as a JSON string literal (with surrounding quotes and JSON escapes)
/// so that embedded quotes, backslashes, and control characters render correctly.
/// </summary>
private static string EncodeJsonString(string value)
{
return JsonSerializer.Serialize(value);
}
}
Loading
Loading