diff --git a/src/SharpClient.Core/Rendering/AnsiParser.cs b/src/SharpClient.Core/Rendering/AnsiParser.cs index 73d1854..c2f2ffc 100644 --- a/src/SharpClient.Core/Rendering/AnsiParser.cs +++ b/src/SharpClient.Core/Rendering/AnsiParser.cs @@ -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) @@ -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. ) doesn't truncate the tag. + var gt = FindTagEnd(line, index + 1); if (gt < 0) { text.Append(current); // unterminated tag: treat '<' as literal @@ -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++; } @@ -215,7 +229,7 @@ void HandleTag(string raw) j++; } - return body[start..j]; + return DecodeEntities(body[start..j]); } else { @@ -225,7 +239,7 @@ void HandleTag(string raw) j++; } - return body[start..j]; + return DecodeEntities(body[start..j]); } } @@ -235,6 +249,140 @@ void HandleTag(string raw) return null; } + /// + /// Scan from 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. + /// + 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; + } + + /// Decode every HTML entity in a string (used for attribute values). + 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(); + } + + /// + /// Try to decode the HTML entity beginning at (must point at '&'). + /// Handles the core named entities, &apos;/&nbsp;/&copy;/&reg;/&trade;, + /// and numeric &#NN; (decimal) / &#xNN; (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 '&' (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). + /// + 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) diff --git a/tests/SharpClient.Tests/Rendering/PuebloEntityTests.cs b/tests/SharpClient.Tests/Rendering/PuebloEntityTests.cs new file mode 100644 index 0000000..92f2c44 --- /dev/null +++ b/tests/SharpClient.Tests/Rendering/PuebloEntityTests.cs @@ -0,0 +1,97 @@ +using SharpClient.Core.Rendering; + +namespace SharpClient.Tests.Rendering; + +/// +/// HTML-entity handling for Pueblo/MXP markup. In Pueblo HTML mode the server entity-encodes the +/// reserved characters (e.g. a literal '>' is sent as "&gt;"), so the client must decode them +/// on render — but only inside markup mode, and a lone/unknown '&' must pass through literally. +/// +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 > 3 && 2 < 4 "q"", Pueblo())) + .IsEqualTo("5 > 3 && 2 < 4 \"q\""); + } + + [Test] + public async Task DecodesAposAndNbsp() + { + await Assert.That(Render("it's here", Pueblo())).IsEqualTo("it's here"); + } + + [Test] + public async Task DecodesNumericDecimalAndHex() + { + await Assert.That(Render(">>&", 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("ab", 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¬athing;y", Pueblo())).IsEqualTo("x¬athing;y"); + } + + [Test] + public async Task DoesNotDecodeEntitiesOutsideMarkupMode() + { + // No MXP/Pueblo active: '&' and entities are plain text, never decoded. + await Assert.That(Render("5 > 3", null)).IsEqualTo("5 > 3"); + } + + [Test] + public async Task DecodesEntitiesInMxpMode() + { + await Assert.That(Render("5 > 3", new MxpParserState { IsMxpActive = true })) + .IsEqualTo("5 > 3"); + } + + [Test] + public async Task EntityInsideAttributeValueIsDecoded() + { + var segs = AnsiParser.Parse("go", 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(" 3\">go", 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"); + } +}