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
160 changes: 154 additions & 6 deletions src/SharpClient.Core/Rendering/AnsiParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ void HandleTag(string raw)
inSend = true;
}
}
else if (name == "a" && mxp.IsPuebloActive)
else if (name == "a" && mxp!.IsPuebloActive)
{
var cmd = GetAttr(attrs, "xch_cmd");
if (cmd is not null)
Expand Down Expand Up @@ -155,10 +155,14 @@ void HandleTag(string raw)
}

// MXP / Pueblo markup. Locked MXP lines (mode 2) suppress all parsing.
if (current == '<' && mxp is not null
&& (mxp.IsPuebloActive || (mxp.IsMxpActive && mxp.LineMode != 2)))
var markup = mxp is not null
&& (mxp.IsPuebloActive || (mxp.IsMxpActive && mxp.LineMode != 2));

if (current == '<' && markup)
{
var gt = line.IndexOf('>', index);
// Quote-aware scan for the tag terminator so a '>' inside a quoted attribute
// value (e.g. <a xch_cmd="say 5 > 3">) doesn't truncate the tag.
var gt = FindTagEnd(line, index + 1);
if (gt < 0)
{
text.Append(current); // unterminated tag: treat '<' as literal
Expand All @@ -171,6 +175,16 @@ void HandleTag(string raw)
continue;
}

// HTML entity decoding only happens inside MXP/Pueblo markup (after tag tokenisation,
// on text runs). In plain ANSI output '&' is always literal. A lone or unrecognised
// '&' is emitted verbatim — never dropped.
if (current == '&' && markup && TryDecodeEntity(line, index, out var entity, out var consumed))
{
text.Append(entity);
index += consumed;
continue;
}

text.Append(current);
index++;
}
Expand Down Expand Up @@ -215,7 +229,7 @@ void HandleTag(string raw)
j++;
}

return body[start..j];
return DecodeEntities(body[start..j]);
}
else
{
Expand All @@ -225,7 +239,7 @@ void HandleTag(string raw)
j++;
}

return body[start..j];
return DecodeEntities(body[start..j]);
}
}

Expand All @@ -235,6 +249,140 @@ void HandleTag(string raw)
return null;
}

/// <summary>
/// Scan from <paramref name="start"/> for the '>' that closes a tag, skipping over single- or
/// double-quoted attribute values so a literal '>' inside an attribute doesn't end the tag early
/// (the Pueblo reference client guards this exact case). Returns -1 if unterminated.
/// </summary>
private static int FindTagEnd(string s, int start)
{
var quote = '\0';
for (var k = start; k < s.Length; k++)
{
var c = s[k];
if (quote != '\0')
{
if (c == quote)
{
quote = '\0';
}
}
else if (c is '"' or '\'')
{
quote = c;
}
else if (c == '>')
{
return k;
}
}

return -1;
}

/// <summary>Decode every HTML entity in a string (used for attribute values).</summary>
private static string DecodeEntities(string s)
{
if (s.IndexOf('&') < 0)
{
return s;
}

var sb = new StringBuilder(s.Length);
var i = 0;
while (i < s.Length)
{
if (s[i] == '&' && TryDecodeEntity(s, i, out var decoded, out var consumed))
{
sb.Append(decoded);
i += consumed;
}
else
{
sb.Append(s[i]);
i++;
}
}

return sb.ToString();
}

/// <summary>
/// Try to decode the HTML entity beginning at <paramref name="i"/> (must point at '&amp;').
/// Handles the core named entities, <c>&amp;apos;</c>/<c>&amp;nbsp;</c>/<c>&amp;copy;</c>/<c>&amp;reg;</c>/<c>&amp;trade;</c>,
/// and numeric <c>&amp;#NN;</c> (decimal) / <c>&amp;#xNN;</c> (hex), decoding to full Unicode.
/// Named lookup is case-insensitive (friendlier than the reference client). A terminating ';'
/// is required; an unrecognised or unterminated sequence returns false so the caller emits the
/// literal '&amp;' (entities are never silently dropped). Numeric values below U+0020 or outside
/// the Unicode range are consumed but produce no output (control chars are ignored, per MXP).
/// </summary>
private static bool TryDecodeEntity(string s, int i, out string decoded, out int consumed)
{
decoded = string.Empty;
consumed = 0;

if (i >= s.Length || s[i] != '&')
{
return false;
}

var semi = s.IndexOf(';', i + 1);
if (semi < 0 || semi - (i + 1) is 0 or > 12)
{
return false; // no terminator, empty, or implausibly long
}

var name = s[(i + 1)..semi];
consumed = semi - i + 1;

if (name[0] == '#')
{
var isHex = name.Length > 1 && name[1] is 'x' or 'X';
var digits = isHex ? name[2..] : name[1..];
var v = 0;
var ok = digits.Length > 0 && (isHex
? int.TryParse(digits, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out v)
: int.TryParse(digits, out v));
if (!ok)
{
consumed = 0;
return false;
}

if (v < 0x20 || v > 0x10FFFF || (v >= 0xD800 && v <= 0xDFFF))
{
decoded = string.Empty; // ignore control / invalid / surrogate code points
return true;
}

decoded = char.ConvertFromUtf32(v);
return true;
}

var mapped = name.ToLowerInvariant() switch
{
"lt" => "<",
"gt" => ">",
"amp" => "&",
"quot" => "\"",
"apos" => "'",
"nbsp" => " ",
"copy" => "©",
"reg" => "®",
"trade" => "™",
_ => null,
};

if (mapped is null)
{
consumed = 0;
return false;
}

decoded = mapped;
return true;
}

private static bool IsCsiFinal(char c) => c is >= '@' and <= '~';

private static TextStyle ApplySgr(TextStyle style, string parameters)
Expand Down
97 changes: 97 additions & 0 deletions tests/SharpClient.Tests/Rendering/PuebloEntityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using SharpClient.Core.Rendering;

namespace SharpClient.Tests.Rendering;

/// <summary>
/// HTML-entity handling for Pueblo/MXP markup. In Pueblo HTML mode the server entity-encodes the
/// reserved characters (e.g. a literal '&gt;' is sent as "&amp;gt;"), so the client must decode them
/// on render — but only inside markup mode, and a lone/unknown '&amp;' must pass through literally.
/// </summary>
public sealed class PuebloEntityTests
{
private static MxpParserState Pueblo() => new() { IsPuebloActive = true };

private static string Render(string line, MxpParserState? mxp)
{
var sb = new System.Text.StringBuilder();
foreach (var seg in AnsiParser.Parse(line, mxp))
{
sb.Append(seg.Text);
}

return sb.ToString();
}

[Test]
public async Task DecodesCoreEntitiesInPuebloMode()
{
await Assert.That(Render("5 &gt; 3 &amp;&amp; 2 &lt; 4 &quot;q&quot;", Pueblo()))
.IsEqualTo("5 > 3 && 2 < 4 \"q\"");
}

[Test]
public async Task DecodesAposAndNbsp()
{
await Assert.That(Render("it&apos;s&nbsp;here", Pueblo())).IsEqualTo("it's here");
}

[Test]
public async Task DecodesNumericDecimalAndHex()
{
await Assert.That(Render("&#62;&#x3E;&#38;", Pueblo())).IsEqualTo(">>&");
}

[Test]
public async Task IgnoresControlRangeNumericEntities()
{
// U+0007 (BEL) is below U+0020: consumed but produces no output (matches MXP behaviour).
await Assert.That(Render("a&#7;b", Pueblo())).IsEqualTo("ab");
}

[Test]
public async Task LoneAmpersandPassesThroughLiterally()
{
await Assert.That(Render("Tom & Jerry", Pueblo())).IsEqualTo("Tom & Jerry");
}

[Test]
public async Task UnknownEntityPassesThroughLiterally()
{
await Assert.That(Render("x&notathing;y", Pueblo())).IsEqualTo("x&notathing;y");
}

[Test]
public async Task DoesNotDecodeEntitiesOutsideMarkupMode()
{
// No MXP/Pueblo active: '&' and entities are plain text, never decoded.
await Assert.That(Render("5 &gt; 3", null)).IsEqualTo("5 &gt; 3");
}

[Test]
public async Task DecodesEntitiesInMxpMode()
{
await Assert.That(Render("5 &gt; 3", new MxpParserState { IsMxpActive = true }))
.IsEqualTo("5 > 3");
}

[Test]
public async Task EntityInsideAttributeValueIsDecoded()
{
var segs = AnsiParser.Parse("<a xch_cmd=\"say 5 &gt; 3\">go</a>", Pueblo());

await Assert.That(segs.Count).IsEqualTo(1);
await Assert.That(segs[0].Text).IsEqualTo("go");
await Assert.That(segs[0].Command).IsEqualTo("say 5 > 3");
}

[Test]
public async Task RawGreaterThanInsideQuotedAttributeDoesNotTruncateTag()
{
// A literal '>' inside a quoted attribute must not end the tag early.
var segs = AnsiParser.Parse("<a xch_cmd=\"say 5 > 3\">go</a>", Pueblo());

await Assert.That(segs.Count).IsEqualTo(1);
await Assert.That(segs[0].Text).IsEqualTo("go");
await Assert.That(segs[0].Command).IsEqualTo("say 5 > 3");
}
}
Loading