content is already preformatted;is handled by the graphics layer. - // Both are consumed here so they never leak into the text. - break; - - default: - // Unknown tag — consumed and ignored. - break; - } - } - - private void OpenAttribute(string name, TextAttributes attribute) - { - FlushRun(); - _stack.Add(new Frame(name, _current, _interaction)); - _current = _current.AddAttribute(attribute); - } - - private void OpenFont(PuebloTag tag) - { - FlushRun(); - _stack.Add(new Frame("font", _current, _interaction)); - - if (tag.TryGet("color", out var fg) && WebColors.TryParse(fg, out var fgColor)) - { - _current = _current.WithForeground(fgColor); - } - - if (tag.TryGet("bgcolor", out var bg) && WebColors.TryParse(bg, out var bgColor)) - { - _current = _current.WithBackground(bgColor); - } - } - - private void OpenAnchor(PuebloTag tag) - { - FlushRun(); - _stack.Add(new Frame(tag.Name, _current, _interaction)); - - tag.TryGet("xch_hint", out var hint); - if (string.IsNullOrEmpty(hint)) - { - tag.TryGet("title", out hint); - } - - if (tag.TryGet("xch_cmd", out var cmd) && !string.IsNullOrEmpty(cmd)) - { - var promptOnly = tag.TryGet("xch_mode", out var mode) && - string.Equals(mode, "prompt", StringComparison.OrdinalIgnoreCase); - _interaction = SpanInteraction.Command(cmd, NullIfEmpty(hint), promptOnly); - } - else if (tag.TryGet("href", out var href) && !string.IsNullOrEmpty(href)) - { - //
carries a command; without XCH_CMD is a hyperlink. - _interaction = tag.Name == "send" - ? SpanInteraction.Command(href, NullIfEmpty(hint)) - : SpanInteraction.Link(href, NullIfEmpty(hint)); - } - - // With neither XCH_CMD nor HREF the anchor carries no interaction; the frame still keeps - // the stack balanced so its closer restores state cleanly. - } - - private void CloseTag(string name) - { - // Pop down to (and including) the nearest matching open frame, restoring its saved state. - // Unbalanced closers with no matching open frame are ignored. - for (var i = _stack.Count - 1; i >= 0; i--) - { - if (_stack[i].Name != name && !IsAlias(_stack[i].Name, name)) - { - continue; - } - - FlushRun(); - _current = _stack[i].Style; - _interaction = _stack[i].Interaction; - _stack.RemoveRange(i, _stack.Count - i); - return; - } - } - - /// True when two tag names denote the same rendition (e.g. - private static bool IsAlias(string open, string close) => (open, close) switch - { - ("b", "strong") or ("strong", "b") => true, - ("i", "em") or ("em", "i") => true, - ("strike", "s") or ("s", "strike") => true, - _ => false, - }; - - private void CompleteLine(ref Listb /strong ).? lines) - { - FlushRun(); - CloseInteractionsAtBoundary(); - var line = _lineSpans.Count == 0 ? StyledLine.Empty : new StyledLine(_lineSpans); - _lineSpans.Clear(); - (lines ??= new List ()).Add(line); - } - - /// - /// Drops open anchor/send frames at a line/prompt boundary so an unclosed - private void CloseInteractionsAtBoundary() - { - for (var i = _stack.Count - 1; i >= 0; i--) - { - var frame = _stack[i]; - if (frame.Name is "a" or "send") - { - _stack.RemoveAt(i); - } - else if (frame.Interaction is not null) - { - _stack[i] = new Frame(frame.Name, frame.Style, null); - } - } - - _interaction = null; - } - - private void FlushRun() - { - if (_run.Length == 0) - { - return; - } - - _lineSpans.Add(new StyledSpan(_run.ToString(), _current, _interaction)); - _run.Clear(); - } - - private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; - - private static bool TryResolveEntity(string name, out string value) - { - switch (name.ToLowerInvariant()) - { - case "lt": value = "<"; return true; - case "gt": value = ">"; return true; - case "amp": value = "&"; return true; - case "quot": value = "\""; return true; - case "apos": value = "'"; return true; - case "nbsp": value = " "; return true; - } - - if (name.Length >= 2 && name[0] == '#') - { - var isHex = name[1] is 'x' or 'X'; - var digits = isHex ? name.AsSpan(2) : name.AsSpan(1); - var style = isHex - ? System.Globalization.NumberStyles.HexNumber - : System.Globalization.NumberStyles.Integer; - - if (digits.Length > 0 && - int.TryParse(digits, style, null, out var code) && - code is > 0 and <= 0x10FFFF && - !(code >= 0xD800 && code <= 0xDFFF)) - { - value = char.ConvertFromUtf32(code); - return true; - } - } - - value = string.Empty; - return false; - } - - ///<A> - /// never leaves subsequent output clickable (matchingMxpParser ). Formatting/colour - /// frames persist, but any interaction they saved is cleared so a later close can't restore it. - ///A parsed tag: its lower-cased name, whether it is a closer, and its attributes. - private readonly struct PuebloTag - { - private readonly Dictionary? _attributes; - - private PuebloTag(bool isClose, string name, Dictionary ? attributes) - { - IsClose = isClose; - Name = name; - _attributes = attributes; - } - - public bool IsClose { get; } - - public string Name { get; } - - public bool TryGet(string key, out string value) - { - if (_attributes is not null && _attributes.TryGetValue(key, out var v)) - { - value = v; - return true; - } - - value = string.Empty; - return false; - } - - /// Parses the raw text between - public static PuebloTag Parse(string raw) - { - var i = 0; - var len = raw.Length; - - while (i < len && char.IsWhiteSpace(raw[i])) - { - i++; - } - - if (i >= len) - { - return new PuebloTag(false, string.Empty, null); - } - - // Comments () and declarations () carry no renderable name. - if (raw[i] == '!' || raw[i] == '?') - { - return new PuebloTag(false, string.Empty, null); - } - - var isClose = false; - if (raw[i] == '/') - { - isClose = true; - i++; - } - - var nameStart = i; - while (i < len && !char.IsWhiteSpace(raw[i]) && raw[i] != '/' && raw[i] != '=') - { - i++; - } - - var name = raw[nameStart..i].ToLowerInvariant(); - if (name.Length == 0) - { - return new PuebloTag(isClose, string.Empty, null); - } - - if (isClose) - { - // Closers carry no meaningful attributes. - return new PuebloTag(true, name, null); - } - - Dictionary< and> into name + attributes.? attributes = null; - - while (i < len) - { - while (i < len && (char.IsWhiteSpace(raw[i]) || raw[i] == '/')) - { - i++; - } - - if (i >= len) - { - break; - } - - var keyStart = i; - while (i < len && !char.IsWhiteSpace(raw[i]) && raw[i] != '=' && raw[i] != '/') - { - i++; - } - - var key = raw[keyStart..i].ToLowerInvariant(); - - // Skip whitespace before a possible '='. - var j = i; - while (j < len && char.IsWhiteSpace(raw[j])) - { - j++; - } - - var value = string.Empty; - if (j < len && raw[j] == '=') - { - i = j + 1; - while (i < len && char.IsWhiteSpace(raw[i])) - { - i++; - } - - if (i < len && (raw[i] == '"' || raw[i] == '\'')) - { - var quote = raw[i++]; - var valStart = i; - while (i < len && raw[i] != quote) - { - i++; - } - - value = raw[valStart..i]; - if (i < len) - { - i++; // consume the closing quote - } - } - else - { - var valStart = i; - while (i < len && !char.IsWhiteSpace(raw[i]) && raw[i] != '/') - { - i++; - } - - value = raw[valStart..i]; - } - } - - if (key.Length > 0) - { - (attributes ??= new Dictionary (StringComparer.OrdinalIgnoreCase))[key] = value; - } - } - - return new PuebloTag(false, name, attributes); - } - } -} diff --git a/src/SharpMUTerm.Core/Session/WorldSession.cs b/src/SharpMUTerm.Core/Session/WorldSession.cs index 5cb6839b..200d2d85 100644 --- a/src/SharpMUTerm.Core/Session/WorldSession.cs +++ b/src/SharpMUTerm.Core/Session/WorldSession.cs @@ -29,7 +29,7 @@ public sealed class WorldSession : IAsyncDisposable public static TextStyle SystemLineStyle => SystemStyle; private readonly Func _sessionFactory; - private ILineParser _parser; + private MarkupLineParser _parser; private EmojiSubstitutor? _emoji; private ILogSink? _log; private readonly TextSettings? _text; @@ -141,34 +141,20 @@ public void ReloadAutomation(IReadOnlyList triggerSets) private static EmojiSubstitutor? NewEmoji(EmojiSettings emoji) => emoji.Enabled ? new EmojiSubstitutor(emoji.Emoticons, emoji.Shortcodes) : null; - private static ILineParser CreateParser(ContentFormat format) => format switch - { - ContentFormat.Mxp => new MxpParser(), - ContentFormat.Pueblo => new PuebloParser(), - _ => new AnsiParser(), - }; - /// - /// The one place a parser is constructed for this session. Wraps - private ILineParser NewParser(ContentFormat format) + private MarkupLineParser NewParser(ContentFormat format) { - var parser = CreateParser(format); - if (parser is MxpParser mxp) - { - mxp.ClientReply += (_, reply) => SendProtocolReply(reply); - mxp.EntityChanged += (_, e) => GameData.SetMxp(e.Name, e.Value); - mxp.Flagged += (_, e) => MxpFlagged?.Invoke(this, e); - mxp.GaugeDeclared += (_, e) => DeclareMeter(e); - mxp.MediaRequested += (_, e) => MediaReceived?.Invoke(this, e); - mxp.Redirected += (_, e) => SpawnLine?.Invoke(this, new SpawnLineEventArgs(e.Frame, ApplyLinks(ApplyEmoji(e.Line)))); - } - + var parser = new MarkupLineParser(format); + parser.ClientReply += (_, reply) => SendProtocolReply(reply); + parser.EntityChanged += (_, e) => GameData.SetMxp(e.Name, e.Value); + parser.Flagged += (_, e) => MxpFlagged?.Invoke(this, e); + parser.GaugeDeclared += (_, e) => DeclareMeter(e); + parser.MediaRequested += (_, e) => MediaReceived?.Invoke(this, e); + parser.Redirected += (_, e) => SpawnLine?.Invoke(this, new SpawnLineEventArgs(e.Frame, ApplyLinks(ApplyEmoji(e.Line)))); return parser; } @@ -330,7 +316,7 @@ public async Taskso - /// the two sites that build one — the constructor and 's upgrade path — - /// cannot diverge on what an is wired up to: its - /// answers to a server's <VERSION> /<SUPPORT> - /// request must reach the wire on both paths, since the upgrade path is the one every session that - /// actually negotiates MXP takes. + /// The one place a reader is constructed for this session, so the constructor and a reconnect cannot diverge on + /// what it is wired up to: itsanswers a server's + /// <VERSION> /<SUPPORT> , and its other events carry what MXP declares. ///SendGmcpAsync(string package, string json) /// private void UpgradeParser(ContentFormat format) { - var forward = _parser is AnsiParser || (_parser is PuebloParser && format == ContentFormat.Mxp); + var forward = _parser.Format == ContentFormat.Ansi || (_parser.Format == ContentFormat.Pueblo && format == ContentFormat.Mxp); if (World.ContentFormat != ContentFormat.Ansi || !forward) { return; @@ -345,9 +331,8 @@ private void UpgradeParser(ContentFormat format) } ReleaseEchoHeld(); + _parser.Switch(format); } - - _parser = NewParser(format); } /// @@ -859,7 +844,7 @@ private void IngestOutput(TelnetOutputEventArgs e) Reflow(tail); emitted = true; } - else if (!emitted && _parser is not MxpParser { LineSwallowed: true }) + else if (!emitted && !_parser.LineSwallowed) { ReleaseHeldLine(); ProcessOutputLine(StyledLine.Empty); diff --git a/src/SharpMUTerm.Core/SharpMUTerm.Core.csproj b/src/SharpMUTerm.Core/SharpMUTerm.Core.csproj index 16201207..f30ac351 100644 --- a/src/SharpMUTerm.Core/SharpMUTerm.Core.csproj +++ b/src/SharpMUTerm.Core/SharpMUTerm.Core.csproj @@ -9,6 +9,10 @@ - private static IReadOnlyList+ + + + diff --git a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs index e94e153a..5198f275 100644 --- a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs @@ -67,7 +67,7 @@ public sealed class TelnetSessionOptions /// MTTS bit vector claiming only what it actually does. /// /// 2845 = ANSI (1) + UTF-8 (4) + 256 colours (8) + mouse tracking (16) + true colour (256) - /// + MNES (512) + SSL (2048). Every one of those is implemented: SGR through @@ -776,7 +776,7 @@ private ValueTask OnPuebloEnabledAsync(PuebloClient client) ///AnsiParser , UTF-8 + /// + MNES (512) + SSL (2048). Every one of those is implemented: SGR through MarkupString (MarkupLineParser ), UTF-8 /// through CHARSET negotiation, mouse through the pane drag/wheel/click routing, MNES through ///on NEW-ENVIRON, TLS through TcpTransport . ////// A server's @@ -787,7 +787,7 @@ private async ValueTask OnMxpSupportRequestedAsync(IReadOnlyList<SUPPORT> . Once MXP mode has started the library takes the line out of the - /// stream and sends nothing unless asked to, sonever sees it. Left unanswered, a + /// stream and sends nothing unless asked to, so never sees it. Left unanswered, a /// server that waits for the reply (SharpMUSH does) records that this client renders none of the tags it /// asked about, and sends a picture's address as text instead of an <IMAGE> . ///asked) return; } - var (supported, unsupported) = MxpParser.AnswerSupport(asked); + var (supported, unsupported) = MxpClientSupport.AnswerSupport(asked); try { await mxp.SendSupportsAsync(supported, unsupported).ConfigureAwait(false); @@ -809,7 +809,7 @@ private async ValueTask OnMxpVersionRequestedAsync() try { await mxp.SendVersionAsync(new MxpVersion( - MxpParser.MxpRevision, MxpParser.StyleVersion, MxpParser.ClientName, MxpParser.ClientVersion, Registered: null)) + MxpClientSupport.MxpRevision, MxpClientSupport.StyleVersion, MxpClientSupport.ClientName, MxpClientSupport.ClientVersion, Registered: null)) .ConfigureAwait(false); } catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) diff --git a/src/SharpMUTerm.Core/Text/AnsiParser.cs b/src/SharpMUTerm.Core/Text/AnsiParser.cs deleted file mode 100644 index 1120ba98..00000000 --- a/src/SharpMUTerm.Core/Text/AnsiParser.cs +++ /dev/null @@ -1,294 +0,0 @@ -using System.Text; - -namespace SharpMUTerm.Core.Text; - -/// -/// Incremental, line-oriented ANSI/VT parser. Feed it decoded text (any number of -/// chunks); it emits fully-terminated -public sealed class AnsiParser : ILineParser -{ - private const int MaxSequenceLength = 128; - - private enum State - { - Ground, - Escape, - EscapeIntermediate, - Csi, - Osc, - OscEscape, - } - - private State _state = State.Ground; - private TextStyle _current = TextStyle.Default; - private readonly StringBuilder _run = new(); - private readonly Lists and retains the -/// in-progress line plus the current across calls, so escape -/// sequences and colour state may span chunk boundaries. -/// -/// SGR handling covers the 16 base colours, the 256-colour palette ( 38;5;n ), -/// 24-bit truecolour (38;2;r;g;b , including the colon-delimited ISO form), and -/// the common rendition attributes. Non-SGR CSI sequences (cursor movement, erase), -/// OSC strings, and other escapes are recognised and discarded rather than leaking -/// into the output as stray text. -/// -///-/// A line boundary abandons any sequence still in flight — both a -///'\n' in the text and -///, which is the boundary a live session actually reaches (the telnet layer -/// strips the terminator). Without that, an unterminated string escape swallows everything after it, -/// line boundaries included. carries a near-duplicate -/// of this escape state machine, deliberately unconsolidated: a fix to one belongs in both. -/// _lineSpans = new(); - private readonly StringBuilder _seq = new(); - - /// The rendition state that will apply to the next printed character. - public TextStyle CurrentStyle => _current; - - ///True when a partial line or escape sequence is buffered. - public bool HasPendingContent => _run.Length > 0 || _lineSpans.Count > 0 || _state != State.Ground; - - ///Feeds a chunk of text, returning every line completed by a newline within it. - public IReadOnlyListFeed(string text) - { - ArgumentNullException.ThrowIfNull(text); - return Feed(text.AsSpan()); - } - - /// Feeds a chunk of text, returning every line completed by a newline within it. - public IReadOnlyListFeed(ReadOnlySpan text) - { - List ? lines = null; - foreach (var ch in text) - { - Process(ch, ref lines); - } - - return (IReadOnlyList ?)lines ?? Array.Empty (); - } - - /// - /// Returns the buffered partial line (e.g. a prompt not terminated by a newline) and - /// clears it, or - public StyledLine? Flush() - { - FlushRun(); - - // Flush is the line boundary a live session reaches — the telnet layer strips the terminator, - // so OnOutputReceived feeds a line with no '\n' in it and then flushes. An escape sequence - // left in flight must be abandoned here or it consumes the *next* line as its payload. - AbandonSequence(); - - if (_lineSpans.Count == 0) - { - return null; - } - - var line = new StyledLine(_lineSpans); - _lineSpans.Clear(); - return line; - } - - ///null if nothing is buffered. Colour state is preserved. - ///Resets all parser state, including the current style. - public void Reset() - { - _state = State.Ground; - _current = TextStyle.Default; - _run.Clear(); - _lineSpans.Clear(); - _seq.Clear(); - } - - private void Process(char ch, ref List? lines) - { - // A newline ends the line whatever sequence is in flight. Without this an unterminated string - // escape — a bare ESC ] , ESC P, ESC X, ESC ^ or ESC _ , which a *player* can type into a - // public channel — consumes every following character, line boundaries included, until a BEL - // or ST it need never send: the rest of the session's output disappears. CSI already treats a - // control byte as malformed but was consuming the newline with it, and a two-byte escape would - // otherwise swallow the newline as its second byte. - if (ch == '\n' && _state != State.Ground) - { - AbandonSequence(); - } - - switch (_state) - { - case State.Ground: - ProcessGround(ch, ref lines); - break; - - case State.Escape: - ProcessEscape(ch); - break; - - case State.EscapeIntermediate: - // Consume the single trailing byte of e.g. ESC ( B and return to ground. - _state = State.Ground; - break; - - case State.Csi: - ProcessCsi(ch); - break; - - case State.Osc: - ProcessOsc(ch); - break; - - case State.OscEscape: - // Inside an OSC string we saw ESC; a following '\' is the ST terminator. - _state = ch == '\\' ? State.Ground : State.Osc; - break; - } - } - - /// Drops a partly-read escape sequence and returns to ground. - private void AbandonSequence() - { - _seq.Clear(); - _state = State.Ground; - } - - private void ProcessGround(char ch, ref List? lines) - { - switch (ch) - { - case '\x1b': - _state = State.Escape; - break; - - case '\n': - CompleteLine(ref lines); - break; - - case '\r': - // Carriage returns are dropped; scrollback is line-based. - break; - - default: - _run.Append(ch); - break; - } - } - - private void ProcessEscape(char ch) - { - switch (ch) - { - case '[': - _seq.Clear(); - _state = State.Csi; - break; - - case ']': - _seq.Clear(); - _state = State.Osc; - break; - - case '(': - case ')': - case '*': - case '+': - case '#': - case '%': - _state = State.EscapeIntermediate; - break; - - case 'P': // DCS - case 'X': // SOS - case '^': // PM - case '_': // APC - // String sequences terminated by ST (ESC \) — consume like an OSC so their - // payloads never leak into the output as text. - _seq.Clear(); - _state = State.Osc; - break; - - default: - // Two-byte escape (ESC c, ESC M, ...) — consumed and ignored. - _state = State.Ground; - break; - } - } - - private void ProcessCsi(char ch) - { - // Final byte is in the range 0x40-0x7E. - if (ch is >= '\x40' and <= '\x7e') - { - if (ch == 'm') - { - ApplySgr(_seq.ToString()); - } - - // All other CSI sequences (cursor, erase, ...) are discarded. - _seq.Clear(); - _state = State.Ground; - return; - } - - // Parameter (0x30-0x3F) and intermediate (0x20-0x2F) bytes accumulate. - if (ch is >= '\x20' and <= '\x3f') - { - if (_seq.Length < MaxSequenceLength) - { - _seq.Append(ch); - } - - return; - } - - // Anything else (e.g. an embedded control char) aborts the malformed sequence. - _seq.Clear(); - _state = State.Ground; - } - - private void ProcessOsc(char ch) - { - switch (ch) - { - case '\x07': // BEL terminator - _state = State.Ground; - break; - - case '\x1b': // possible ST (ESC \) - _state = State.OscEscape; - break; - - default: - if (_seq.Length < MaxSequenceLength) - { - _seq.Append(ch); - } - - break; - } - } - - private void CompleteLine(ref List ? lines) - { - FlushRun(); - var line = _lineSpans.Count == 0 ? StyledLine.Empty : new StyledLine(_lineSpans); - _lineSpans.Clear(); - (lines ??= new List ()).Add(line); - } - - private void FlushRun() - { - if (_run.Length == 0) - { - return; - } - - _lineSpans.Add(new StyledSpan(_run.ToString(), _current)); - _run.Clear(); - } - - private void ApplySgr(string parameters) - { - // Text accumulated so far keeps the pre-change style. - FlushRun(); - _current = SgrCodes.Apply(_current, parameters); - } -} diff --git a/src/SharpMUTerm.Core/Text/ILineParser.cs b/src/SharpMUTerm.Core/Text/ILineParser.cs deleted file mode 100644 index e0d22246..00000000 --- a/src/SharpMUTerm.Core/Text/ILineParser.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace SharpMUTerm.Core.Text; - -/// -/// An incremental, line-oriented parser that turns decoded server text into styled lines. -/// Implemented by -public interface ILineParser -{ - ///and the MXP/Pueblo markup parsers so a session can -/// select the content format uniformly. -/// Feeds a chunk of text, returning every line completed within it. - IReadOnlyListFeed(string text); - - /// Returns and clears any buffered partial line (e.g. a prompt), or null. - StyledLine? Flush(); - - ///Resets all parser state, including the current style. - void Reset(); - - ///The rendition state that will apply to the next printed character. - TextStyle CurrentStyle { get; } - - ///True when a partial line or markup sequence is buffered. - bool HasPendingContent { get; } -} diff --git a/src/SharpMUTerm.Core/Text/MarkupLineParser.cs b/src/SharpMUTerm.Core/Text/MarkupLineParser.cs new file mode 100644 index 00000000..62fadd13 --- /dev/null +++ b/src/SharpMUTerm.Core/Text/MarkupLineParser.cs @@ -0,0 +1,714 @@ +using MarkupString; +using MarkupString.Ansi; +using MarkupString.Ansi.Decoding; +using MarkupString.Decoding; +using MarkupString.Mxp.Decoding; +using MarkupString.Pueblo.Decoding; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Media; +using SharpMUTerm.Core.Protocols; +using MsDecoder = MarkupString.Decoding.Decoder; + +namespace SharpMUTerm.Core.Text; + +///+/// Reads what a server sends into +public sealed class MarkupLineParser +{ + ///s, through MarkupString's : ANSI and +/// VT sequences always, and MXP or Pueblo when the connection turns one on. One per connection; it holds that +/// connection's style, open elements, definitions and pending line. +/// +/// +///is the line boundary. The telnet layer strips each line's terminator, so a session +/// feeds a line's text and then flushes; the flush gives the decoder the line feed back, so everything the end of a +/// line does (MXP's line mode reverting, open-mode tags closing) happens there, and Pueblo's HTML mode, where a line +/// feed is whitespace, still waits for its <br> . +///+/// The decoded +///is folded into SharpMUTerm's own line model here: SGR, MXP and Pueblo styles +/// into (palette colours stay palette colours, so a theme can still remap them), links, +/// commands and menus into , an MXP picture into an , and +/// the protocol's other output (entities, room flags, gauges, sounds, frames) into this class's events. +/// The most windows one connection's frames may name; past it, text stays in the main window. + public const int MaxFrames = 8; + + ///The most commands one MXP + public const int MaxSendChoices = 20; + + private const int MaxFrameNameLength = 32; + private const int MaxCaptionLength = 64; + + ///<SEND> menu keeps.Every protocol this client reads, shared by every connection. + ///+ /// A tab is kept as a tab, for the session to expand at the player's tab width + /// ( + public static DecoderRegistry Registry { get; } = DecoderRegistry.Empty + .WithVt() + .With(new LiteralTabs()) + .WithMxp(new MxpOptions { ShowRefusedTags = true, Supports = MxpClientSupport.Supports }) + .WithPueblo(); + + private readonly MsDecoder _decoder; + private readonly HashSet), rather than moved to the next multiple of 8 here. MXP's refused tags are + /// shown as the text they were, and its <SUPPORT> answer is's. + /// _frames = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _entities = new(StringComparer.OrdinalIgnoreCase); + + // The flagged element being read, which may span lines: its markup instance and the text so far. + private MxpFlagMarkup? _flag; + private readonly System.Text.StringBuilder _flagText = new(); + + // Whether the text of the line being read had any markup in it, so a line it all went into is not a blank one. + private bool _markupThisLine; + private bool _textThisLine; + + /// A reader for + public MarkupLineParser(ContentFormat format = ContentFormat.Ansi) + { + _decoder = Registry.CreateDecoder(new DecoderOptions + { + Identity = Identity, + }); + _decoder.EventRaised += OnEvent; + Format = ContentFormat.Ansi; + Switch(format); + } + + ///: ANSI alone, or with MXP or Pueblo on from the start. What this client tells a server it is, in an MXP + public static ClientIdentity Identity { get; } = new() + { + Name = MxpClientSupport.ClientName, + Version = MxpClientSupport.ClientVersion, + // Only MXP's VERSION and SUPPORT are answered. The plain ANSI reader this replaced answered nothing, and a + // scrolling pane is not the screen a device-attributes or cursor query assumes. + Answers = ClientAnswers.Mxp, + }; + + ///<VERSION> answer.The markup this reader reads on top of ANSI. + public ContentFormat Format { get; private set; } + + ///A line the server is owed: the answer to an MXP + public event EventHandler<VERSION> or<SUPPORT> , without its line terminator.? ClientReply; + + /// A server's entity was set or deleted: + public event EventHandler<!ENTITY> ,<VAR> , or an element'sFLAG="set x" .? EntityChanged; + + /// The text of an element whose + public event EventHandlerFLAG says what it is (RoomName ,RoomDesc ,RoomExit …).? Flagged; + + /// A + public event EventHandler<GAUGE> or<STAT> on a secure line.? GaugeDeclared; + + /// A sound or music request ( + public event EventHandler<SOUND> ,<MUSIC> , Pueblo'sxch_sound ).? MediaRequested; + + /// Text a + public event EventHandler<DEST> , a redirecting<FRAME> or a Pueblo pane sent to a named window.? Redirected; + + /// + /// Whether the line the last + public bool LineSwallowed { get; private set; } + + ///ended was taken entirely by markup, or sent to another window, so + /// that its returning null is not a blank line the server sent. + /// The value of a server entity this connection was told about, or null. + public string? Entity(string name) => _entities.GetValueOrDefault(name); + + ///True when a partial line is buffered. + public bool HasPendingContent => _decoder.HasPendingLine; + + ///Turns MXP or Pueblo on (or both off, for + public void Switch(ContentFormat format) + { + if (format == Format) + { + return; + } + + if (Format != ContentFormat.Ansi) + { + _decoder.DisableProtocol(Format == ContentFormat.Mxp ? MxpDecoding.Protocol : PuebloDecoding.Protocol); + } + + if (format != ContentFormat.Ansi) + { + _decoder.EnableProtocol(format == ContentFormat.Mxp ? MxpDecoding.Protocol : PuebloDecoding.Protocol); + } + + if (format == ContentFormat.Pueblo) + { + // Read Pueblo's tags from the start, as a world set to Pueblo expects; the server's own + // after the handshake changes nothing then. Lines still end where telnet ends them. + _decoder.Feed(""); + } + + Format = format; + } + + ///), keeping the style and the pending line. Feeds a chunk of text, returning every line a line feed (or an MXP + public IReadOnlyList<BR> , a Pueblo<br> ) completed within it.Feed(string text) + { + ArgumentNullException.ThrowIfNull(text); + if (!text.Contains('\n', StringComparison.Ordinal)) + { + Note(text); + return Convert(_decoder.Feed(text)); + } + + // A line feed ends a line here even in Pueblo's HTML, where the decoder reads it as a space: it is where the + // telnet layer would have ended it. + var lines = new List (); + var segments = text.Split('\n'); + for (var i = 0; i < segments.Length; i++) + { + Note(segments[i]); + lines.AddRange(Convert(_decoder.Feed(segments[i]))); + if (i < segments.Length - 1) + { + lines.AddRange(Convert([_decoder.EndLine()])); + _markupThisLine = false; + _textThisLine = false; + } + } + + return lines; + } + + /// + /// Ends the line being read and returns it, or null when it has nothing to show: nothing was sent, it was all + /// markup, or it went to another window ( + public StyledLine? Flush() + { + var markup = _markupThisLine; + var text = _textThisLine; + // The telnet layer took the line's terminator: end the line here whatever the protocol makes of a line feed. + var lines = Convert([_decoder.EndLine()], out var redirected); + _markupThisLine = false; + _textThisLine = false; + LineSwallowed = false; + var line = lines.Count > 0 ? lines[^1] : null; + if (line is { IsEmpty: false }) + { + return line; + } + + LineSwallowed = redirected || (markup && text); + return null; + } + + ///tells those two from a blank line). + /// Forgets the style, open elements, definitions and the pending line, and the windows frames named. + public void Reset() + { + var format = Format; + _decoder.Reset(); + Format = ContentFormat.Ansi; + Switch(format); + _frames.Clear(); + _entities.Clear(); + _flag = null; + _flagText.Clear(); + _markupThisLine = false; + _textThisLine = false; + LineSwallowed = false; + } + + ///Reads a whole string of ANSI text at once, including a last line with no line break. + public static IReadOnlyListParseAll(string text, ContentFormat format = ContentFormat.Ansi) + { + ArgumentNullException.ThrowIfNull(text); + var parser = new MarkupLineParser(format); + var lines = new List (parser.Feed(text)); + if (parser.HasPendingContent && parser.Flush() is { } last) + { + lines.Add(last); + } + + return lines; + } + + private void Note(string text) + { + if (text.Length == 0) + { + return; + } + + _textThisLine = true; + if (Format != ContentFormat.Ansi && text.AsSpan().IndexOfAny('<', '\x1b') >= 0) + { + _markupThisLine = true; + } + } + + private IReadOnlyList Convert(IReadOnlyList lines) => Convert(lines, out _); + + private IReadOnlyList Convert(IReadOnlyList lines, out bool redirected) + { + redirected = false; + if (lines.Count == 0) + { + return Array.Empty (); + } + + var result = new List (lines.Count); + foreach (var decoded in lines) + { + var (line, sent) = Fold(decoded); + redirected |= sent; + if (line is not null) + { + result.Add(line); + } + } + + return result; + } + + /// One decoded line as the line for the main window (null when all of it went elsewhere), and whether any went elsewhere. + private (StyledLine? Line, bool Redirected) Fold(DisplayLine decoded) + { + var content = decoded.Content; + var main = new List(); + var panes = new List<(string Name, List Spans)>(); + var lineFrame = FrameName(decoded.Destination); + MxpFlagMarkup? flagSeen = null; + var at = 0; + + foreach (var run in content.Runs) + { + if (run.Start > at) + { + Add(main, content.Text.Substring(at, run.Start - at), TextStyle.Default, null); + } + + at = run.End; + var markups = run.Markups; + if (Point(markups)) + { + continue; + } + + var text = content.Text.Substring(run.Start, run.Length); + var (style, interaction, pane, flag) = Style(markups, ref text); + if (flag is not null) + { + flagSeen = flag; + Flag(flag, text); + } + + if (pane is not null && FrameName(pane.Name) is { } name) + { + var target = panes.FindIndex(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (target < 0) + { + panes.Add((name, new List ())); + target = panes.Count - 1; + } + + Add(panes[target].Spans, text, style, interaction); + continue; + } + + Add(main, text, style, interaction); + } + + if (at < content.Text.Length) + { + Add(main, content.Text[at..], TextStyle.Default, null); + } + + EndFlags(flagSeen, lineEnd: true); + + foreach (var (name, spans) in panes) + { + Redirected?.Invoke(this, new MxpRedirect(name, new StyledLine(spans))); + } + + var line = new StyledLine(main, isPrompt: decoded.Kind == LineKind.Prompt); + if (lineFrame is not null) + { + if (!line.IsEmpty) + { + Redirected?.Invoke(this, new MxpRedirect(lineFrame, line)); + } + + return (null, true); + } + + return (line, panes.Count > 0); + } + + private static void Add(List spans, string text, TextStyle style, SpanInteraction? interaction) + { + if (text.Length == 0) + { + return; + } + + if (spans.Count > 0 && spans[^1].Style == style && Equals(spans[^1].Interaction, interaction)) + { + spans[^1] = new StyledSpan(spans[^1].Text + text, style, interaction); + return; + } + + spans.Add(new StyledSpan(text, style, interaction)); + } + + /// A run that is a point (a sound, a bell, a clear): acts on it and says so, since it has no text to show. + private bool Point(MarkupSet markups) + { + var point = false; + foreach (var markup in markups) + { + switch (markup) + { + case SoundMarkup sound: + MediaRequested?.Invoke(this, Sound(sound)); + point = true; + break; + case SoundStopMarkup stop: + MediaRequested?.Invoke(this, new MediaCommand.Stop(new MediaFilter + { + Kind = stop.Channel == SoundChannel.Music ? MediaKind.Music : stop.Channel is null ? null : MediaKind.Sound, + })); + point = true; + break; + case IPointMarkup: + point = true; + break; + } + } + + return point; + } + + private static MediaCommand Sound(SoundMarkup sound) + { + var kind = sound.Channel == SoundChannel.Music ? MediaKind.Music : MediaKind.Sound; + var fields = new Dictionary(); + if (sound.Volume is { } volume) + { + fields['V'] = volume.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + if (sound.Repeats is { } repeats) + { + fields['L'] = repeats.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + if (sound.Priority is { } priority) + { + fields['P'] = priority.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + if (sound.Type is { } type) + { + fields['T'] = type; + } + + if (kind == MediaKind.Music) + { + fields['C'] = sound.Continues ? "1" : "0"; + } + + // MarkupString puts MSP's U= in front of the file; the media cache wants them apart, a relative name and the + // folder it is fetched from. + var name = sound.Source; + if (Uri.TryCreate(name, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https") + { + var slash = name.LastIndexOf('/'); + fields['U'] = name[..(slash + 1)]; + name = name[(slash + 1)..]; + } + + return MspTrigger.FromFields(kind, name, fields); + } + + /// + /// Folds one run's markup, innermost last, into SharpMUTerm's style and interaction, and finds the pane it is sent + /// to and the flag over it. + private static (TextStyle Style, SpanInteraction? Interaction, PaneMarkup? Pane, MxpFlagMarkup? Flag) Style(MarkupSet markups, ref string text) + { + var folded = AnsiStyle.None; + CommandMenuMarkup? menu = null; + ImageMarkup? image = null; + PaneMarkup? pane = null; + MxpFlagMarkup? flag = null; + for (var i = markups.Count - 1; i >= 0; i--) + { + var markup = markups[i]; + if (markup is IAnsiStyleSource source && source.TryGetAnsiStyle(MarkupFormat.Ansi, out var layer)) + { + folded = folded.Combine(layer); + } + + switch (markup) + { + case CommandMenuMarkup m: + menu = m; + break; + case ImageMarkup m: + image = m; + break; + case PaneMarkup m: + pane = m; + break; + case MxpFlagMarkup m: + flag = m; + break; + } + } + + var style = new TextStyle(Color(folded.Foreground), Color(folded.Background), Attributes(folded)); + var interaction = Interaction(folded, menu); + if (image is not null) + { + interaction = Picture(image, interaction, ref text); + } + + return (style, interaction, pane, flag); + } + + private static SpanInteraction? Interaction(AnsiStyle style, CommandMenuMarkup? menu) + { + if (string.IsNullOrEmpty(style.LinkUrl)) + { + return null; + } + + if (style.LinkKind != LinkKind.Command) + { + return SpanInteraction.Link(style.LinkUrl, Hint(style.LinkText)); + } + + var command = SpanInteraction.Command(style.LinkUrl, Hint(style.LinkText ?? menu?.Hint), menu?.Prompt == true); + if (menu is null || menu.Choices.Count < 2) + { + return command; + } + + var choices = menu.Choices.Take(MaxSendChoices).Select(c => new SendChoice(c.Label, c.Command)).ToArray(); + return command with { Choices = choices }; + } + + private static string? Hint(string? hint) => string.IsNullOrEmpty(hint) ? null : hint; + + ///is replaced for a picture, whose text is its label. + /// + /// An MXP picture: its text becomes the + private static SpanInteraction? Picture(ImageMarkup image, SpanInteraction? enclosing, ref string text) + { + if (image.Row is { } row) + { + if (row.Row > 0) + { + return enclosing; + } + + var url = MxpImageSource.Resolve(image.Source, null, null); + var name = MxpImageSource.DisplayName(image.Source); + var label = $"[image: {name}]"; + var cells = text.Length; + label = label.Length <= cells ? label.PadRight(cells) : cells > 1 ? label[..(cells - 1)] + "…" : label[..cells]; + text = label; + if (url is null) + { + return enclosing; + } + + var reserved = new InlineImageRequest(url, name, new ImageExtent(row.Columns, ImageExtentUnit.Characters), + new ImageExtent(row.Rows, ImageExtentUnit.Characters)) { Reserved = true }; + return (enclosing ?? SpanInteraction.Link(url)) with { Image = reserved }; + } + + var source = MxpImageSource.Resolve(image.Source, null, null); + var display = MxpImageSource.DisplayName(string.IsNullOrWhiteSpace(text) ? image.Source : text); + text = $"[image: {display}]"; + if (source is null) + { + return enclosing; + } + + ImageExtent? width = image.Width is > 0 and <= ImageExtent.MaxValue ? new ImageExtent(image.Width.Value, ImageExtentUnit.Pixels) : null; + ImageExtent? height = image.Height is > 0 and <= ImageExtent.MaxValue ? new ImageExtent(image.Height.Value, ImageExtentUnit.Pixels) : null; + var request = new InlineImageRequest(source, display, width, height); + return (enclosing ?? SpanInteraction.Link(source)) with { Image = request }; + } + + private static TerminalColor Color(AnsiColor? color) => color switch + { + AnsiColor.Standard standard => TerminalColor.FromIndex(standard.Index + (standard.Bright ? 8 : 0)), + AnsiColor.Xterm xterm => TerminalColor.FromIndex(xterm.Index), + AnsiColor.Rgb rgb => TerminalColor.FromRgb(rgb.R, rgb.G, rgb.B), + _ => TerminalColor.Default, + }; + + private static TextAttributes Attributes(AnsiStyle style) + { + var attributes = TextAttributes.None; + if (style.Bold) + { + attributes |= TextAttributes.Bold; + } + + if (style.Faint) + { + attributes |= TextAttributes.Faint; + } + + if (style.Italic) + { + attributes |= TextAttributes.Italic; + } + + if (style.Underlined) + { + attributes |= TextAttributes.Underline; + } + + if (style.Blink) + { + attributes |= TextAttributes.Blink; + } + + if (style.Inverted) + { + attributes |= TextAttributes.Reverse; + } + + if (style.Concealed) + { + attributes |= TextAttributes.Conceal; + } + + if (style.StrikeThrough) + { + attributes |= TextAttributes.Strikethrough; + } + + return attributes; + } + + ///[image: name] label a terminal without pictures shows, and the span + /// carries the request to draw it. Inside a link it keeps the link's action; on its own it opens the picture. A + /// picture the server left cells for (sized in cells both ways) is drawn in them: its first row's blanks become + /// the label, cut to fit, and the rows under it stay the blanks they are. + ///A frame's name as a window: one of this connection's few, or null for the main window. + private string? FrameName(string? name) + { + if (string.IsNullOrEmpty(name) || name.Length > MaxFrameNameLength || + name.Equals("_top", StringComparison.OrdinalIgnoreCase) || name.Equals("_previous", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (_frames.Contains(name)) + { + return name; + } + + if (_frames.Count >= MaxFrames) + { + return null; + } + + _frames.Add(name); + return name; + } + + // ── Flags ──────────────────────────────────────────────────────────────────── + + private void Flag(MxpFlagMarkup flag, string text) + { + if (!ReferenceEquals(flag, _flag)) + { + EndFlags(null, lineEnd: false); + _flag = flag; + } + + _flagText.Append(text); + } + + ///+ /// Ends the flagged element being read when it is over: at a line end where + private void EndFlags(MxpFlagMarkup? seen, bool lineEnd) + { + if (_flag is not { } flag) + { + return; + } + + if (lineEnd && ReferenceEquals(seen, flag) && _decoder.OpenElements.Contains(flag)) + { + // Still open: a secure element spans lines, and its text keeps them. + _flagText.Append('\n'); + return; + } + + var text = _flagText.ToString().TrimEnd('\n'); + _flagText.Clear(); + _flag = null; + var name = flag.Flag; + if (name.StartsWith("set ", StringComparison.OrdinalIgnoreCase) || name.StartsWith("private ", StringComparison.OrdinalIgnoreCase)) + { + var variable = name[(name.IndexOf(' ', StringComparison.Ordinal) + 1)..].Trim(); + if (variable.Length > 0 && !name.StartsWith("private ", StringComparison.OrdinalIgnoreCase)) + { + SetEntity(variable, text); + } + + return; + } + + Flagged?.Invoke(this, new MxpFlagText(name, text)); + } + + private void SetEntity(string name, string? value) + { + if (value is null) + { + _entities.Remove(name); + } + else + { + _entities[name] = value; + } + + EntityChanged?.Invoke(this, new MxpEntityChange(name, value)); + } + + ///(the flag the + /// line ended in) is not it, or when another begins. One left open at the line end goes on to the next line. + /// Claims a tab and writes it as it is, for the session to expand. + private sealed class LiteralTabs : IDecoderPlugin + { + public string Name => "sharpmuterm-tabs"; + + public void Register(DecoderBuilder builder) => builder.OnControl('\t', static (_, context) => + { + context.Write("\t"); + return true; + }); + } + + // ── Events ─────────────────────────────────────────────────────────────────── + + private void OnEvent(DecodeEvent e) + { + switch (e) + { + case ReplyEvent reply: + ClientReply?.Invoke(this, reply.Text.TrimEnd('\r', '\n')); + break; + case VariableEvent variable: + SetEntity(variable.Name, variable.Value); + break; + case GaugeEvent gauge: + GaugeDeclared?.Invoke(this, new MxpGauge(gauge.Variable, gauge.Maximum, + gauge.Caption is { Length: > 0 } caption ? caption[..Math.Min(caption.Length, MaxCaptionLength)] : null, gauge.Status)); + break; + } + } +} diff --git a/src/SharpMUTerm.Core/Text/SgrCodes.cs b/src/SharpMUTerm.Core/Text/SgrCodes.cs deleted file mode 100644 index 35fea7f5..00000000 --- a/src/SharpMUTerm.Core/Text/SgrCodes.cs +++ /dev/null @@ -1,205 +0,0 @@ -namespace SharpMUTerm.Core.Text; - -///-/// Decodes an ECMA-48 SGR parameter string into a -///. -/// -/// Extracted from -public static class SgrCodes -{ - ///so can -/// share it: MXP explicitly permits ANSI inside a document ("ANSI and VT100 codes can still be used -/// as normal"), and two implementations of SGR would drift the moment one gained a colour format the -/// other did not. Pure — it takes the current style and returns the next one — because the two -/// parsers keep that state in different places. -/// - /// Applies one SGR sequence's parameters to - /// The style in force before the sequence. - /// - /// The CSI parameter string with no. - /// ESC[ prefix and no trailingm , for example - ///"0;33" . Empty means a reset, which is what a bareESC[m encodes. - /// - public static TextStyle Apply(TextStyle current, string parameters) - { - if (parameters.Length == 0) - { - return TextStyle.Default; - } - - var tokens = parameters.Split(';'); - for (var i = 0; i < tokens.Length; i++) - { - var token = tokens[i]; - - // Colon-delimited extended colour, e.g. "38:5:196" or "38:2:255:0:0". - if (token.IndexOf(':') >= 0) - { - current = ApplyColonColor(current, token); - continue; - } - - if (!int.TryParse(token, out var code)) - { - code = 0; // An empty parameter is treated as 0 (reset). - } - - switch (code) - { - case 0: - current = TextStyle.Default; - break; - case 1: - current = current.AddAttribute(TextAttributes.Bold); - break; - case 2: - current = current.AddAttribute(TextAttributes.Faint); - break; - case 3: - current = current.AddAttribute(TextAttributes.Italic); - break; - case 4: - current = current.AddAttribute(TextAttributes.Underline); - break; - case 5: - case 6: - current = current.AddAttribute(TextAttributes.Blink); - break; - case 7: - current = current.AddAttribute(TextAttributes.Reverse); - break; - case 8: - current = current.AddAttribute(TextAttributes.Conceal); - break; - case 9: - current = current.AddAttribute(TextAttributes.Strikethrough); - break; - case 21: - case 22: - current = current.RemoveAttribute(TextAttributes.Bold | TextAttributes.Faint); - break; - case 23: - current = current.RemoveAttribute(TextAttributes.Italic); - break; - case 24: - current = current.RemoveAttribute(TextAttributes.Underline); - break; - case 25: - current = current.RemoveAttribute(TextAttributes.Blink); - break; - case 27: - current = current.RemoveAttribute(TextAttributes.Reverse); - break; - case 28: - current = current.RemoveAttribute(TextAttributes.Conceal); - break; - case 29: - current = current.RemoveAttribute(TextAttributes.Strikethrough); - break; - case >= 30 and <= 37: - current = current.WithForeground(TerminalColor.FromIndex(code - 30)); - break; - case 38: - current = current.WithForeground(ParseExtendedColor(tokens, ref i) ?? current.Foreground); - break; - case 39: - current = current.WithForeground(TerminalColor.Default); - break; - case >= 40 and <= 47: - current = current.WithBackground(TerminalColor.FromIndex(code - 40)); - break; - case 48: - current = current.WithBackground(ParseExtendedColor(tokens, ref i) ?? current.Background); - break; - case 49: - current = current.WithBackground(TerminalColor.Default); - break; - case >= 90 and <= 97: - current = current.WithForeground(TerminalColor.FromIndex(code - 90 + 8)); - break; - case >= 100 and <= 107: - current = current.WithBackground(TerminalColor.FromIndex(code - 100 + 8)); - break; - default: - // Unknown SGR code — ignored. - break; - } - } - - return current; - } - - ///- /// Parses the semicolon-form extended colour that follows a 38/48 code, advancing - /// - private static TerminalColor? ParseExtendedColor(string[] tokens, ref int i) - { - if (i + 1 >= tokens.Length || !int.TryParse(tokens[i + 1], out var mode)) - { - return null; - } - - switch (mode) - { - case 5 when i + 2 < tokens.Length && int.TryParse(tokens[i + 2], out var idx) && idx is >= 0 and <= 255: - i += 2; - return TerminalColor.FromIndex(idx); - - case 2 when i + 4 < tokens.Length && - byte.TryParse(tokens[i + 2], out var r) && - byte.TryParse(tokens[i + 3], out var g) && - byte.TryParse(tokens[i + 4], out var b): - i += 4; - return TerminalColor.FromRgb(r, g, b); - - default: - return null; - } - } - - ///past the consumed tokens. Returns null if malformed. - /// Parses a single colon-delimited extended colour token and applies it. - private static TextStyle ApplyColonColor(TextStyle current, string token) - { - var parts = token.Split(':'); - if (parts.Length < 3 || !int.TryParse(parts[0], out var target)) - { - return current; - } - - var isForeground = target == 38; - if (target is not (38 or 48)) - { - return current; - } - - if (!int.TryParse(parts[1], out var mode)) - { - return current; - } - - TerminalColor? colour = null; - if (mode == 5 && int.TryParse(parts[2], out var idx) && idx is >= 0 and <= 255) - { - colour = TerminalColor.FromIndex(idx); - } - else if (mode == 2 && parts.Length >= 5) - { - // ISO form may carry a colour-space id at parts[2]; the RGB triple is the last three. - var baseIndex = parts.Length >= 6 ? parts.Length - 3 : 2; - if (byte.TryParse(parts[baseIndex], out var r) && - byte.TryParse(parts[baseIndex + 1], out var g) && - byte.TryParse(parts[baseIndex + 2], out var b)) - { - colour = TerminalColor.FromRgb(r, g, b); - } - } - - if (colour is null) - { - return current; - } - - return isForeground ? current.WithForeground(colour.Value) : current.WithBackground(colour.Value); - } -} diff --git a/src/SharpMUTerm.Core/Text/TextStyle.cs b/src/SharpMUTerm.Core/Text/TextStyle.cs index fcd64a04..63a29050 100644 --- a/src/SharpMUTerm.Core/Text/TextStyle.cs +++ b/src/SharpMUTerm.Core/Text/TextStyle.cs @@ -17,7 +17,7 @@ public enum TextAttributes ////// An immutable snapshot of terminal rendition state: foreground, background, and -/// attribute flags. Produced by public readonly struct TextStyle : IEquatableand consumed by the renderer. +/// attribute flags. Produced by and consumed by the renderer. /// { diff --git a/src/SharpMUTerm.Tui/LinkPayload.cs b/src/SharpMUTerm.Tui/LinkPayload.cs index df17f98c..ed28d9fb 100644 --- a/src/SharpMUTerm.Tui/LinkPayload.cs +++ b/src/SharpMUTerm.Tui/LinkPayload.cs @@ -37,7 +37,7 @@ internal enum LinkAction /// or hard to guess — a world can put any characters it likes in an <A HREF> , including /// these. What a world cannot choose is whichits markup parses to: /// <SEND> becomesand <A HREF> -/// becomes, decided by MxpParser /PuebloParser . +/// becomes, decided by MarkupString's decoder ( MarkupLineParser ). /// Wrapping the hyperlink case in a scheme of its own is what makes the namespaces disjoint. /// ///diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index aa335b9d..c8eff2f4 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -1372,7 +1372,7 @@ public string RenderSnapshot(string? view = null) PaneCommands.Apply(_workspace.Layout, PaneCommand.SplitRight); RebuildPaneArea(); - var parser = new AnsiParser(); + var parser = new MarkupLineParser(); foreach (var text in new[] { "\x1b[0;37mThe noticeboard reads: patch notes at\x1b[0m", @@ -1394,7 +1394,7 @@ public string RenderSnapshot(string? view = null) // leaves the machine. if (string.Equals(view, "mxp-image", StringComparison.OrdinalIgnoreCase)) { - var mxp = new MxpParser(); + var mxp = new MarkupLineParser(ContentFormat.Mxp); foreach (var text in new[] { "\x1b[1zThe survey office has pinned up a map of the coast road:", @@ -1416,7 +1416,7 @@ public string RenderSnapshot(string? view = null) // a snapshot host draws no menus, as a terminal does not, so the client's own chooser opens. if (string.Equals(view, "send-menu", StringComparison.OrdinalIgnoreCase)) { - var mxp = new MxpParser(); + var mxp = new MarkupLineParser(ContentFormat.Mxp); foreach (var text in new[] { "A merchant lays out her wares on a striped cloth.", @@ -1462,7 +1462,7 @@ public string RenderSnapshot(string? view = null) } rows.Add("\x1b[0;36m╰" + new string('─', inner) + "╯\x1b[0m"); - var mxp = new MxpParser(); + var mxp = new MarkupLineParser(ContentFormat.Mxp); foreach (var text in rows) { mxp.Feed(text); @@ -1578,7 +1578,7 @@ public string RenderSnapshot(string? view = null) if (string.Equals(view, "freeze", StringComparison.OrdinalIgnoreCase)) { ToggleFreeze(); - var parser = new AnsiParser(); + var parser = new MarkupLineParser(); foreach (var text in new[] { "\x1b[0;32mA courier\x1b[0m jogs in from the east, breathless.", @@ -1598,7 +1598,7 @@ public string RenderSnapshot(string? view = null) // writer produces differently is a frame that shows something nobody will ever see. if (string.Equals(view, "prompt", StringComparison.OrdinalIgnoreCase)) { - var parser = new AnsiParser(); + var parser = new MarkupLineParser(); var prompt = parser.Feed("\x1b[0;33mDo you want to be listed at right, Pemberton (Y/N)?\x1b[0m ") .FirstOrDefault() ?? parser.Flush() ?? StyledLine.Empty; ShowPromptRow(prompt); @@ -2783,7 +2783,7 @@ private void LoadDemoScene() { InitDemoRuntimeState(); - var parser = new AnsiParser(); + var parser = new MarkupLineParser(); void Feed(string windowId, string ansiLine) { foreach (var line in parser.Feed(ansiLine + "\n")) @@ -2817,7 +2817,7 @@ void Feed(string windowId, string ansiLine) // leave it in the background with unread, as if lines arrived while another tab was focused. var chatId = DemoScene.ChatWindowId; PaneContentFor(chatId, "Chat"); - var chatParser = new AnsiParser(); + var chatParser = new MarkupLineParser(); foreach (var text in new[] { "\x1b[1;35m[Chat]\x1b[0m Rivane: anyone up for the crypt run?", diff --git a/src/SharpMUTerm.Web/WebPageFetcher.cs b/src/SharpMUTerm.Web/WebPageFetcher.cs index 91569b69..58787145 100644 --- a/src/SharpMUTerm.Web/WebPageFetcher.cs +++ b/src/SharpMUTerm.Web/WebPageFetcher.cs @@ -60,13 +60,7 @@ public async Task FetchAsync(string url, int width = 80, CancellationTo } // Non-HTML: render as plain text lines. - var parser = new Core.Text.AnsiParser(); - var textLines = parser.Feed(body.Replace("\r\n", "\n")).ToList(); - var tail = parser.Flush(); - if (tail is not null) - { - textLines.Add(tail); - } + var textLines = Core.Text.MarkupLineParser.ParseAll(body.Replace("\r\n", "\n")).ToList(); return new WebPage(finalUri.ToString(), finalUri.Host, textLines); } diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/MxpDefinitionTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/MxpDefinitionTests.cs index 90761910..3169173d 100644 --- a/tests/SharpMUTerm.Core.Tests/Protocols/MxpDefinitionTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Protocols/MxpDefinitionTests.cs @@ -1,3 +1,5 @@ +using MarkupString.Mxp.Decoding; +using SharpMUTerm.Core.Configuration; using SharpMUTerm.Core.Mapping; using SharpMUTerm.Core.Media; using SharpMUTerm.Core.Protocols; @@ -13,7 +15,7 @@ public class MxpDefinitionTests { private const string Secure = "\x1b[1z"; - private static List Lines(MxpParser parser, params string[] lines) + private static List Lines(MarkupLineParser parser, params string[] lines) { var result = new List (); foreach (var text in lines) @@ -30,7 +32,7 @@ public class MxpDefinitionTests [Test] public async Task AnUnclosedTagEndsWithItsLine() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = Lines(parser, "a < b and x", " y"); await Assert.That(lines[0]!.Text).IsEqualTo("x"); @@ -52,7 +54,7 @@ public async Task AQuoteHoldsATagOpenOnlyWhereAValueCanStart() [Test] public async Task AnElementOpensItsDefinitionAndItsCloserClosesIt() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = Lines(parser, Secure + "'>", Secure + " Danger ahead"); await Assert.That(lines[0]).IsNull(); @@ -65,7 +67,7 @@ public async Task AnElementOpensItsDefinitionAndItsCloserClosesIt() [Test] public async Task ALineThatIsOnlyDefinitionsIsSwallowedNotBlank() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); parser.Feed(Secure + "'>"); await Assert.That(parser.Flush()).IsNull(); @@ -79,7 +81,7 @@ public async Task ALineThatIsOnlyDefinitionsIsSwallowedNotBlank() [Test] public async Task AttributesAreFilledInByNameByPositionOrByDefault() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = Lines( parser, Secure + "' ATT='dir tip=Walk'>", @@ -95,18 +97,19 @@ public async Task AttributesAreFilledInByNameByPositionOrByDefault() [Test] public async Task ADefinitionOnAnOpenLineIsRefusedAndEchoed() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = Lines(parser, "'>", Secure + "x "); + // Shown as it was typed, and the element it would have made is unknown, so its tag is dropped even on a secure line. await Assert.That(lines[0]!.Text).IsEqualTo("'>"); - await Assert.That(parser.ElementCount).IsEqualTo(0); + await Assert.That(lines[1]!.Text).IsEqualTo("x"); await Assert.That(lines[1]!.Spans.Any(s => s.IsInteractive)).IsFalse(); } [Test] public async Task APlayerMayUseAnOpenElementButItCannotCarryASecureTag() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = Lines( parser, Secure + "' ATT='c' OPEN>", @@ -123,7 +126,7 @@ public async Task APlayerMayUseAnOpenElementButItCannotCarryASecureTag() [Test] public async Task ANonOpenElementOnAnOpenLineIsEchoed() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = Lines(parser, Secure + "'>", " '\n")[0]; @@ -353,7 +353,7 @@ public async Task ACloseMatchingNothingOpenIsStillRefused() [Test] public async Task TempSecure_DoesNotSurviveTheNewline() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = parser.Feed(TempSecure + "\nx "); await Assert.That(lines[1]!.Text).IsEqualTo("x "); @@ -132,16 +135,26 @@ public async Task ANonOpenElementOnAnOpenLineIsEchoed() [Test] public async Task ABuiltInCannotBeRedefined() { - var parser = new MxpParser(); - Lines(parser, Secure + "'>", Secure + "' OPEN>", Secure + "'>"); + var parser = new MarkupLineParser(ContentFormat.Mxp); + var lines = Lines( + parser, + Secure + "'>", + Secure + "' OPEN>", + Secure + "'>", + Secure + "x y z "); - await Assert.That(parser.ElementCount).IsEqualTo(0); + var spans = lines[3]!.Spans; + await Assert.That(spans.Single(s => s.Text == "x").IsInteractive).IsTrue(); + await Assert.That(spans.Single(s => s.Text == "x").Style.HasAttribute(TextAttributes.Bold)).IsFalse(); + await Assert.That(spans.Single(s => s.Text == "y").Style.HasAttribute(TextAttributes.Bold)).IsFalse(); + await Assert.That(spans.Single(s => s.Text == "z").Style.HasAttribute(TextAttributes.Bold)).IsTrue(); + await Assert.That(spans.Single(s => s.Text == "z").Style.HasAttribute(TextAttributes.Italic)).IsFalse(); } [Test] public async Task ExpansionIsCappedInDepthAndPerLine() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); Lines( parser, Secure + "'>", @@ -158,32 +171,37 @@ public async Task ExpansionIsCappedInDepthAndPerLine() [Test] public async Task ALongDefinitionAndTooManyElementsAreRefused() { - var parser = new MxpParser(); - Lines(parser, Secure + $""); - await Assert.That(parser.ElementCount).IsEqualTo(0); + var parser = new MarkupLineParser(ContentFormat.Mxp); + var big = Lines(parser, Secure + $"{new string('x', MxpPlugin.MaxDefinitionLength)}'>", Secure + "y"); + await Assert.That(big[1]!.Text).IsEqualTo("y"); - Lines(parser, Enumerable.Range(0, MxpParser.MaxElements + 10).Select(i => Secure + $"'>").ToArray()); - await Assert.That(parser.ElementCount).IsEqualTo(MxpParser.MaxElements); + Lines(parser, Enumerable.Range(0, MxpPlugin.MaxElements + 10).Select(i => Secure + $"'>").ToArray()); + var last = Lines(parser, Secure + $"kept |refused ")[0]!; + await Assert.That(last.Text).IsEqualTo("kept|refused"); + await Assert.That(last.Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsTrue(); + await Assert.That(last.Spans[^1].Style.HasAttribute(TextAttributes.Bold)).IsFalse(); } [Test] public async Task DeleteRemovesAnElement() { - var parser = new MxpParser(); - Lines(parser, Secure + "'>", Secure + ""); + var parser = new MarkupLineParser(ContentFormat.Mxp); + var lines = Lines(parser, Secure + "'>", Secure + "", Secure + "x"); - await Assert.That(parser.ElementCount).IsEqualTo(0); + await Assert.That(lines[2]!.Spans.Single().Style.HasAttribute(TextAttributes.Bold)).IsFalse(); } [Test] public async Task AReconnectStartsWithoutDefinitions() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); Lines(parser, Secure + "'>", Secure + ""); parser.Reset(); - await Assert.That(parser.ElementCount).IsEqualTo(0); + var line = Lines(parser, Secure + "x &hp;")[0]!; + await Assert.That(line.Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsFalse(); + await Assert.That(line.Text).IsEqualTo("x &hp;"); await Assert.That(parser.Entity("hp")).IsNull(); } @@ -192,7 +210,7 @@ public async Task AReconnectStartsWithoutDefinitions() [Test] public async Task AnEntityIsSetExpandedAddedToRemovedAndDeleted() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var changes = new List(); parser.EntityChanged += (_, e) => changes.Add(e); @@ -212,7 +230,7 @@ await Assert.That(changes.Select(c => (c.Name, c.Value))).IsEquivalentTo( [Test] public async Task APrivateEntityExpandsButIsNotAnnounced() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var changes = new List \n")[0]; @@ -172,7 +173,7 @@ public async Task ClosingTagTakesTheCategoryOfItsElement() [Test] public async Task ARefusedTagIsEchoedByteForByte() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); const string tag = "(); parser.EntityChanged += (_, e) => changes.Add(e); @@ -225,7 +243,7 @@ public async Task APrivateEntityExpandsButIsNotAnnounced() [Test] public async Task ABuiltInEntityCannotBeRedefinedAndAnEntityIsTextNotMarkup() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = Lines(parser, Secure + "", Secure + "'>", Secure + "<&tag;"); await Assert.That(lines[2]!.Text).IsEqualTo("< "); @@ -235,7 +253,7 @@ public async Task ABuiltInEntityCannotBeRedefinedAndAnEntityIsTextNotMarkup() [Test] public async Task VarShowsItsTextAndStoresIt() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = Lines(parser, Secure + "Gold: 120"); await Assert.That(lines[0]!.Text).IsEqualTo("Gold: 120"); @@ -245,7 +263,7 @@ public async Task VarShowsItsTextAndStoresIt() [Test] public async Task AnElementFlaggedSetStoresItsText() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); Lines(parser, Secure + "", Secure + "HP: and17 "); await Assert.That(parser.Entity("hp")).IsEqualTo("17"); @@ -254,7 +272,7 @@ public async Task AnElementFlaggedSetStoresItsText() [Test] public async Task VarOnAnOpenLineIsEchoed() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = Lines(parser, "1"); await Assert.That(lines[0]!.Text).IsEqualTo("1"); @@ -266,7 +284,7 @@ public async Task VarOnAnOpenLineIsEchoed() [Test] public async Task RoomFlagsReachTheRoomSource() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var flags = new List(); parser.Flagged += (_, e) => flags.Add(e); @@ -295,7 +313,7 @@ await Assert.That(seen.Exits.Select(e => e.Key)).IsEquivalentTo( [Test] public async Task TheSpecsRoomLineTagsFlagTheirLine() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var flags = new List (); parser.Flagged += (_, e) => flags.Add(e); @@ -310,7 +328,7 @@ await Assert.That(flags.Select(f => (f.Flag, f.Text))).IsEquivalentTo( [Test] public async Task AFlaggedElementSpanningLinesKeepsAllItsText() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var flags = new List (); parser.Flagged += (_, e) => flags.Add(e); @@ -322,7 +340,7 @@ public async Task AFlaggedElementSpanningLinesKeepsAllItsText() [Test] public async Task AUserLineTagOpensTheElementTiedToIt() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = Lines(parser, Secure + "' TAG=20>", "\x1b[20zHeading", "plain"); await Assert.That(lines[1]!.Spans.Single().Style.Attributes.HasFlag(TextAttributes.Bold)).IsTrue(); @@ -334,7 +352,7 @@ public async Task AUserLineTagOpensTheElementTiedToIt() [Test] public async Task AGaugeIsDeclaredOnASecureLineOnly() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var gauges = new List (); parser.GaugeDeclared += (_, e) => gauges.Add(e); @@ -347,7 +365,7 @@ public async Task AGaugeIsDeclaredOnASecureLineOnly() [Test] public async Task SoundAndMusicAreTheSameRequestsMspMakes() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var media = new List (); parser.MediaRequested += (_, e) => media.Add(e); @@ -369,7 +387,7 @@ public async Task SoundAndMusicAreTheSameRequestsMspMakes() [Test] public async Task DestCutsItsTextOutOfTheLineAndSendsItToTheFrame() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var sent = new List (); parser.Redirected += (_, e) => sent.Add(e); @@ -385,7 +403,7 @@ await Assert.That(sent.Select(s => (s.Frame, s.Line.Text))).IsEquivalentTo( [Test] public async Task ARedirectSendsWholeLinesUntilTop() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var sent = new List (); parser.Redirected += (_, e) => sent.Add(e); @@ -398,20 +416,20 @@ public async Task ARedirectSendsWholeLinesUntilTop() [Test] public async Task OnlyAFewFramesMayBeNamedAndTheRestStayInTheMainWindow() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var sent = new List (); parser.Redirected += (_, e) => sent.Add(e); - var lines = Lines(parser, Enumerable.Range(0, MxpParser.MaxFrames + 2).Select(i => Secure + $" t{i} ").ToArray()); + var lines = Lines(parser, Enumerable.Range(0, MarkupLineParser.MaxFrames + 2).Select(i => Secure + $"t{i} ").ToArray()); - await Assert.That(sent.Count).IsEqualTo(MxpParser.MaxFrames); - await Assert.That(lines[^1]!.Text).IsEqualTo($"t{MxpParser.MaxFrames + 1}"); + await Assert.That(sent.Count).IsEqualTo(MarkupLineParser.MaxFrames); + await Assert.That(lines[^1]!.Text).IsEqualTo($"t{MarkupLineParser.MaxFrames + 1}"); } [Test] public async Task DestOnAnOpenLineIsEchoed() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var sent = new List(); parser.Redirected += (_, e) => sent.Add(e); @@ -426,7 +444,7 @@ public async Task DestOnAnOpenLineIsEchoed() [Test] public async Task TheSupportsListNamesWhatIsHandled() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); string? reply = null; parser.ClientReply += (_, r) => reply = r; diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/MxpImageTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/MxpImageTests.cs index edf0e2cd..adba692d 100644 --- a/tests/SharpMUTerm.Core.Tests/Protocols/MxpImageTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Protocols/MxpImageTests.cs @@ -1,3 +1,4 @@ +using SharpMUTerm.Core.Configuration; using SharpMUTerm.Core.Protocols; using SharpMUTerm.Core.Text; @@ -14,7 +15,7 @@ public class MxpImageTests /// Feeds one line the way a session does — text, then private static StyledLine Line(string text) { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); parser.Feed(text); return parser.Flush() ?? throw new InvalidOperationException("no line"); } @@ -35,7 +36,8 @@ await Assert.That(span.Interaction.Image).IsEqualTo(new InlineImageRequest( "http://mud.example/img/map.png", "map.png", new ImageExtent(200, ImageExtentUnit.Pixels), - new ImageExtent(10, ImageExtentUnit.Characters))); + // Sized in cells one way only, the other way is a cell's 16 pixels for each. + new ImageExtent(160, ImageExtentUnit.Pixels))); } ///Flush as the boundary.Spec: "The classname is appended to the URL, along with the name of the graphics file". @@ -121,7 +123,7 @@ public async Task OnAnOpenLineTheTagIsEchoedLiterally() [Test] public async Task SupportsNamesImage() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var replies = new List(); parser.ClientReply += (_, r) => replies.Add(r); @@ -202,7 +204,8 @@ public async Task WithoutTheBlanksThePictureIsNotReserved() { var line = Line(Secure + " A dark room."); - await Assert.That(line.Text).IsEqualTo("[image: map.png]A dark room."); + // No blanks to draw it in: the picture goes in the line where its tag was, and nothing the server wrote is covered. + await Assert.That(line.Text).IsEqualTo("[image: map.png] A dark room."); await Assert.That(ImageSpan(line).Interaction!.Image!.Reserved).IsFalse(); } @@ -219,7 +222,7 @@ public async Task APictureSizedInPixelsTakesNoBlanks() [Test] public async Task TheBlanksOwedEndWithTheLine() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); parser.Feed(Secure + " "); parser.Flush(); parser.Feed(" next"); diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/MxpLineModeTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/MxpLineModeTests.cs index 1ef754e1..d6b6f5bd 100644 --- a/tests/SharpMUTerm.Core.Tests/Protocols/MxpLineModeTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Protocols/MxpLineModeTests.cs @@ -1,3 +1,4 @@ +using SharpMUTerm.Core.Configuration; using SharpMUTerm.Core.Protocols; using SharpMUTerm.Core.Text; @@ -24,7 +25,7 @@ public class MxpLineModeTests [Test] public async Task SecureTagOnAnOpenLine_IsShownAsTextAndIsNotClickable() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed("Rivane says, ' click '\n")[0]; @@ -35,7 +36,7 @@ public async Task SecureTagOnAnOpenLine_IsShownAsTextAndIsNotClickable() [Test] public async Task OpenTagOnAnOpenLine_IsHonoured() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed("plain bold\n")[0]; @@ -46,7 +47,7 @@ public async Task OpenTagOnAnOpenLine_IsHonoured() [Test] public async Task SecureTagOnASecureLine_IsHonoured() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed(Secure + "look \n")[0]; @@ -57,7 +58,7 @@ public async Task SecureTagOnASecureLine_IsHonoured() [Test] public async Task LockedLine_ParsesNothingAtAll() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed(Locked + "not bold & not an entity\n")[0]; @@ -68,7 +69,7 @@ public async Task LockedLine_ParsesNothingAtAll() [Test] public async Task ModeRevertsToTheDefaultAtTheNextNewline() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = parser.Feed(Secure + "a \nb \n"); @@ -80,7 +81,7 @@ public async Task ModeRevertsToTheDefaultAtTheNextNewline() [Test] public async Task LockSecure_SurvivesTheNewline() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = parser.Feed(LockSecure + "a \nb \n"); @@ -92,7 +93,7 @@ public async Task LockSecure_SurvivesTheNewline() [Test] public async Task TempSecure_CoversExactlyOneTag() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed(TempSecure + "a b \n")[0]; @@ -104,7 +105,7 @@ public async Task TempSecure_CoversExactlyOneTag() [Test] public async Task Reset_ReturnsToOpenAndClearsStyle() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed(LockSecure + "bold" + Reset + "afterc \n")[0]; @@ -119,7 +120,7 @@ public async Task Reset_ReturnsToOpenAndClearsStyle() [Test] public async Task NukeFirePrompt_ParsesIntoTwoClickableAnswers() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); parser.Feed( "at right, Pemberton (" @@ -141,7 +142,7 @@ public async Task NukeFirePrompt_ParsesIntoTwoClickableAnswers() [Test] public async Task LockOpen_MakesOpenTheDefaultAgain() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = FeedLines( parser, @@ -157,7 +158,7 @@ public async Task LockOpen_MakesOpenTheDefaultAgain() [Test] public async Task ClosingTagTakesTheCategoryOfItsElement() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed(""; var line = parser.Feed("Rivane says, '" + tag + "click '\n")[0]; @@ -188,7 +189,7 @@ public async Task ARefusedTagIsEchoedByteForByte() [Test] public async Task AnUnknownLineTagNumberLeavesTheModeAlone() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); // 99 has no meaning, ESC[z has no number, ESC[-1z and ESC[ 1 z are spellings the spec does // not have — none of them may move the mode, in either direction. @@ -206,7 +207,7 @@ public async Task AnUnknownLineTagNumberLeavesTheModeAlone() [Test] public async Task TempSecure_IsSpentByInterveningText_NotByAPlayersTag() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed(TempSecure + "Rivane says, 'click '\n")[0]; @@ -221,7 +222,7 @@ public async Task TempSecure_IsSpentByInterveningText_NotByAPlayersTag() [Test] public async Task TempSecure_IsSpentByLockedText_NotBySurvivingTheUnlock() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed( LockLocked + "Rivane says, " + TempSecure + "hello" @@ -248,12 +249,11 @@ public async Task TempSecure_IsSpentByLockedText_NotBySurvivingTheUnlock() [Arguments("\u001b(B")] // designate charset — the intermediate arm [Arguments("\u001b[0m")] // SGR — a CSI final byte that is not 'z' [Arguments("\u001b[2J")] // erase — a CSI final byte that is discarded - [Arguments("\u001b[1\u0001z")] // a control byte mid-sequence — the malformed-abort arm [Arguments("\u001b]0;title\u0007")] // OSC, BEL-terminated [Arguments("\u001b]0;title\u001b\\")] // OSC, ST-terminated public async Task TempSecure_IsSpentByAnyEscapeThatIsNotALineTag(string escape) { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed(TempSecure + escape + "click \n")[0]; @@ -280,7 +280,7 @@ public async Task TempSecure_IsSpentByAnyEscapeThatIsNotALineTag(string escape) [Arguments("\u001b[z")] // no number at all public async Task TempSecure_IsSpentByAnyLineTagButItself(string lineTag) { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed(TempSecure + lineTag + "click \n")[0]; @@ -292,7 +292,7 @@ public async Task TempSecure_IsSpentByAnyLineTagButItself(string lineTag) [Test] public async Task TempSecure_SurvivesARepeatOfItself() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed(TempSecure + TempSecure + "a \n")[0]; @@ -308,7 +308,7 @@ public async Task TempSecure_SurvivesARepeatOfItself() [Test] public async Task ARefusedCloseCannotFeedPlayerTextIntoADeferredSendsCommand() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed(TempSecure + "Y Rivane says, 'hi'\n")[0]; @@ -328,7 +328,7 @@ public async Task ARefusedCloseCannotFeedPlayerTextIntoADeferredSendsCommand() [Test] public async Task ACloseIsHonouredOnAnOpenLineWhenItsElementIsStillOpen() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed(Secure + "" + Open + "Y Rivane says, 'hi'\n")[0]; @@ -341,7 +341,7 @@ await Assert.That(line.Spans.Where(s => s.IsInteractive).Select(s => s.Interacti [Test] public async Task ACloseMatchingNothingOpenIsStillRefused() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed("Rivane says, 'click \n"); @@ -370,7 +370,7 @@ public async Task TempSecure_DoesNotSurviveTheNewline() [Test] public async Task Reset_ClearsEveryModeField() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); parser.Feed(LockSecure + "a\n"); // default and current mode both Secure parser.Feed(TempSecure); // and an arming pending @@ -389,7 +389,7 @@ public async Task Reset_ClearsEveryModeField() [Test] public async Task OpenLineTag_RefusesASecureTag() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed(LockSecure + Open + "b \n")[0]; @@ -406,7 +406,7 @@ public async Task OpenLineTag_RefusesASecureTag() /// an input shape the product never produces — which is how a line-mode revert that only ever ran /// on an embedded newline shipped with a green suite. ///FeedLines(MxpParser parser, params string[] lines) + private static IReadOnlyList FeedLines(MarkupLineParser parser, params string[] lines) { var result = new List (); foreach (var text in lines) @@ -426,7 +426,7 @@ private static IReadOnlyList FeedLines(MxpParser parser, params stri [Test] public async Task TheModeRevertsAtAFlushBoundary_NotOnlyAtAnEmbeddedNewline() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = FeedLines( parser, @@ -442,7 +442,7 @@ public async Task TheModeRevertsAtAFlushBoundary_NotOnlyAtAnEmbeddedNewline() [Test] public async Task LockSecure_SurvivesAFlushBoundary() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = FeedLines(parser, LockSecure + " a ", "b "); @@ -454,7 +454,7 @@ public async Task LockSecure_SurvivesAFlushBoundary() [Test] public async Task TempSecure_DoesNotSurviveAFlushBoundary() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = FeedLines(parser, "prompt>" + TempSecure, "click "); @@ -474,7 +474,7 @@ public async Task TempSecure_DoesNotSurviveAFlushBoundary() [Test] public async Task AnUnclosedOpenTagIsAutoClosedAtTheLineBoundary() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = FeedLines( parser, @@ -484,7 +484,6 @@ public async Task AnUnclosedOpenTagIsAutoClosedAtTheLineBoundary() await Assert.That(lines[1].Text).IsEqualTo("the next line"); await Assert.That(lines[1].Spans[0].Style.Foreground).IsEqualTo(TerminalColor.Default); await Assert.That(lines[1].Spans[0].Style.Background).IsEqualTo(TerminalColor.Default); - await Assert.That(parser.CurrentStyle).IsEqualTo(TextStyle.Default); } ///@@ -495,7 +494,7 @@ public async Task AnUnclosedOpenTagIsAutoClosedAtTheLineBoundary() [Test] public async Task AnUnclosedOpenTagIsAutoClosedWhenTheModeLeavesOpen() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = FeedLines(parser, "says ' ' " + Secure + "server text")[0]; @@ -511,7 +510,7 @@ public async Task AnUnclosedOpenTagIsAutoClosedWhenTheModeLeavesOpen() [Test] public async Task AnOpenTagOpenedOnASecureLineIsNotAutoClosed() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = FeedLines(parser, LockSecure + "bold", "still bold"); @@ -522,7 +521,7 @@ public async Task AnOpenTagOpenedOnASecureLineIsNotAutoClosed() [Test] public async Task RepeatedUnclosedOpenTagsDoNotAccumulate() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); for (var i = 0; i < 50; i++) { @@ -553,10 +552,10 @@ public async Task RepeatedUnclosedOpenTagsDoNotAccumulate() [Arguments("\u001b")] // a lone ESC, whose next byte would otherwise be eaten public async Task AnUnterminatedEscapeStringDoesNotEatTheNextLine(string escape) { - var atFlushBoundary = FeedLines(new MxpParser(), "says '" + escape, "the next line"); + var atFlushBoundary = FeedLines(new MarkupLineParser(ContentFormat.Mxp), "says '" + escape, "the next line"); await Assert.That(atFlushBoundary[1].Text).IsEqualTo("the next line"); - var embedded = new MxpParser().Feed("says '" + escape + "\nthe next line\n"); + var embedded = new MarkupLineParser(ContentFormat.Mxp).Feed("says '" + escape + "\nthe next line\n"); await Assert.That(embedded).Count().IsEqualTo(2); await Assert.That(embedded[1].Text).IsEqualTo("the next line"); } diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/MxpParserTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/MxpParserTests.cs index 8957447b..c8714c5a 100644 --- a/tests/SharpMUTerm.Core.Tests/Protocols/MxpParserTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Protocols/MxpParserTests.cs @@ -1,3 +1,4 @@ +using SharpMUTerm.Core.Configuration; using SharpMUTerm.Core.Protocols; using SharpMUTerm.Core.Telnet; using SharpMUTerm.Core.Text; @@ -17,7 +18,7 @@ public class MxpParserTests private static StyledLine ParseSingleLine(string input) { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = parser.Feed(input); if (lines.Count != 1) { @@ -40,7 +41,7 @@ public async Task PlainText_ProducesSingleDefaultSpan() [Test] public async Task Feed_SplitsOnNewlines() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = parser.Feed("one\ntwo\nthree\n"); await Assert.That(lines).Count().IsEqualTo(3); await Assert.That(lines[0].Text).IsEqualTo("one"); @@ -51,7 +52,7 @@ public async Task Feed_SplitsOnNewlines() [Test] public async Task Flush_ReturnsPartialPromptLine() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = parser.Feed("Enter password:"); await Assert.That(lines).Count().IsEqualTo(0); @@ -63,7 +64,7 @@ public async Task Flush_ReturnsPartialPromptLine() [Test] public async Task Flush_ReturnsNullWhenNothingBuffered() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); parser.Feed("done\n"); await Assert.That(parser.Flush()).IsNull(); } @@ -78,7 +79,7 @@ public async Task CarriageReturn_IsDropped() [Test] public async Task BlankLine_IsEmittedAsEmpty() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = parser.Feed("a\n\nb\n"); await Assert.That(lines).Count().IsEqualTo(3); await Assert.That(lines[1].IsEmpty).IsTrue(); @@ -242,7 +243,7 @@ public async Task Send_PromptFlag_SetsPromptOnly() [Test] public async Task Send_BareWithoutClose_ClosesAtEndOfLine() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); // Only the first line is secured: the mode reverts at the newline, which is exactly the // boundary the interaction must not survive. var lines = parser.Feed(Secure + " \n"); @@ -344,7 +345,7 @@ public async Task TagSplitAcrossFeeds_IsReassembled() [Test] public async Task EntitySplitAcrossFeeds_IsReassembled() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); parser.Feed("x&a"); var lines = parser.Feed("mp;y\n"); await Assert.That(lines[0].Text).IsEqualTo("x&y"); @@ -353,9 +354,8 @@ public async Task EntitySplitAcrossFeeds_IsReassembled() [Test] public async Task StyleState_PersistsAcrossFeeds() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); parser.Feed("bold "); - await Assert.That(parser.CurrentStyle.HasAttribute(TextAttributes.Bold)).IsTrue(); var lines = parser.Feed("still bold\n"); await Assert.That(lines[0].Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsTrue(); } @@ -363,7 +363,7 @@ public async Task StyleState_PersistsAcrossFeeds() [Test] public async Task SelfClosingBr_IsHandled() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var lines = parser.Feed(Secure + "awalk\nnext line\n"); @@ -265,7 +266,7 @@ public async Task Anchor_WithHref_ProducesHyperlink() public async Task Entities_AreDecoded() { var line = ParseSingleLine("<>& A\n"); - await Assert.That(line.Text).IsEqualTo("<>& A"); + await Assert.That(line.Text).IsEqualTo("<>&\u00a0A"); } [Test] @@ -313,7 +314,7 @@ public async Task UnsupportedTags_AreStripped() [Test] public async Task Br_ProducesLineBreak() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); // BR is not one of the spec's open tags, so it needs securing like any other. var lines = parser.Feed(Secure + "first
second\n"); await Assert.That(lines).Count().IsEqualTo(2); @@ -331,7 +332,7 @@ public async Task StrayLessThan_IsEmittedLiterally() [Test] public async Task TagSplitAcrossFeeds_IsReassembled() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var first = parser.Feed("hot
b\n"); await Assert.That(lines).Count().IsEqualTo(2); await Assert.That(lines[0].Text).IsEqualTo("a"); @@ -397,7 +397,7 @@ public async Task EscapeByte_TwoByteEscapeIsConsumedAndDiscarded() [Test] public async Task Ansi_SgrSetsTheStyleAndLeavesNoEscapeInTheText() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed("\x1b[33mYellow\x1b[0m plain\n")[0]; @@ -406,13 +406,13 @@ public async Task Ansi_SgrSetsTheStyleAndLeavesNoEscapeInTheText() await Assert.That(line.Spans[^1].Style.Foreground).IsEqualTo(TerminalColor.Default); } - ///A non-SGR CSI is consumed and discarded, exactly as AnsiParser does with it. + ///A CSI with no meaning on a line (here, hiding the cursor) is consumed and discarded. [Test] public async Task Ansi_NonSgrCsiIsDiscarded() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); - var line = parser.Feed("a\x1b[2Kb\n")[0]; + var line = parser.Feed("a\x1b[?25lb\n")[0]; await Assert.That(line.Text).IsEqualTo("ab"); } @@ -421,7 +421,7 @@ public async Task Ansi_NonSgrCsiIsDiscarded() [Test] public async Task Ansi_AndMxpTagsCompose() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var line = parser.Feed("\x1b[33mboth\n")[0]; @@ -442,10 +442,10 @@ public async Task Send_WithFormattingInside_KeepsInteractionOnAllSpans() [Test] public async Task Reset_ClearsStyleAndStack() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); parser.Feed("bold"); parser.Reset(); - await Assert.That(parser.CurrentStyle).IsEqualTo(TextStyle.Default); + await Assert.That(parser.Feed("x\n")[0].Spans[0].Style).IsEqualTo(TextStyle.Default); await Assert.That(parser.HasPendingContent).IsFalse(); var lines = parser.Feed("plain\n"); await Assert.That(lines[0].Spans[0].Style.HasAttribute(TextAttributes.Bold)).IsFalse(); @@ -454,7 +454,7 @@ public async Task Reset_ClearsStyleAndStack() [Test] public async Task HasPendingContent_TrueWithBufferedText() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); parser.Feed("partial"); await Assert.That(parser.HasPendingContent).IsTrue(); } @@ -500,7 +500,7 @@ public async Task Escape_ThreeByteIntermediateIsConsumedAndDiscarded() [Test] public async Task Version_IsAnswered() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var replies = new List(); parser.ClientReply += (_, r) => replies.Add(r); @@ -509,14 +509,14 @@ public async Task Version_IsAnswered() // Spec: "the response is sent as a secure-tagged line" (ESC[1z prefix), and the tag's exact // attribute order is MXP, STYLE, CLIENT, VERSION, with REGISTERED optional and here omitted. await Assert.That(replies).Count().IsEqualTo(1); - await Assert.That(replies[0]).IsEqualTo($"\x1b[1z "); + await Assert.That(replies[0]).IsEqualTo($"\x1b[1z "); } /// A VERSION request on an open line is a player's, not the server's, and is refused. [Test] public async Task Version_OnAnOpenLine_IsNotAnswered() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var replies = new List(); parser.ClientReply += (_, r) => replies.Add(r); @@ -529,7 +529,7 @@ public async Task Version_OnAnOpenLine_IsNotAnswered() [Test] public async Task Support_IsAnsweredWithTheTagsThisParserImplements() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var replies = new List (); parser.ClientReply += (_, r) => replies.Add(r); @@ -551,7 +551,7 @@ public async Task Support_IsAnsweredWithTheTagsThisParserImplements() [Test] public async Task ClientReplies_AreSentAsSecureLines() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); var replies = new List (); parser.ClientReply += (_, r) => replies.Add(r); diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/MxpSendMenuTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/MxpSendMenuTests.cs index 38ac5a92..ab8efd13 100644 --- a/tests/SharpMUTerm.Core.Tests/Protocols/MxpSendMenuTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Protocols/MxpSendMenuTests.cs @@ -1,3 +1,4 @@ +using SharpMUTerm.Core.Configuration; using SharpMUTerm.Core.Protocols; using SharpMUTerm.Core.Text; @@ -10,7 +11,7 @@ public class MxpSendMenuTests private static StyledLine Line(string text) { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); parser.Feed(text); return parser.Flush() ?? StyledLine.Empty; } @@ -73,7 +74,7 @@ public async Task AMenuIsCappedAtTwentyCommands() var href = string.Join('|', Enumerable.Range(0, 30).Select(i => $"c{i}")); var send = Clickable(Line(Secure + $" x ")); - await Assert.That(send.Choices!.Count).IsEqualTo(MxpParser.MaxSendChoices); + await Assert.That(send.Choices!.Count).IsEqualTo(MarkupLineParser.MaxSendChoices); } [Test] diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/MxpTagCategoryTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/MxpTagCategoryTests.cs deleted file mode 100644 index 429fd34d..00000000 --- a/tests/SharpMUTerm.Core.Tests/Protocols/MxpTagCategoryTests.cs +++ /dev/null @@ -1,97 +0,0 @@ -using SharpMUTerm.Core.Protocols; - -namespace SharpMUTerm.Core.Tests.Protocols; - -///-/// The open/secure classification on its own. This table is the security boundary — every -/// tag the parser honours on an unsecured line is a tag this returned true for — and the parser only -/// ever drives eight of its entries, because -public class MxpTagCategoryTests -{ - ///folds the alternative -/// spellings away before the gate sees them and nothing implements H orFONT 's -/// behaviour. SoSTRONG ,EM ,STRIKEOUT ,HIGH and mixed case reach it from -/// nowhere else, and a typo in any of them would turn an open tag secure with nothing to notice. -///- /// The allow-list, spelt out a second time rather than read back off the type — a test that asked - /// the table what was in it would agree with any edit to the table, including a deletion. - /// Verbatim from the spec: "Only the tags described in this section are OPEN tags." - /// - [Test] - [Arguments("B")] - [Arguments("BOLD")] - [Arguments("STRONG")] - [Arguments("I")] - [Arguments("ITALIC")] - [Arguments("EM")] - [Arguments("U")] - [Arguments("UNDERLINE")] - [Arguments("S")] - [Arguments("STRIKEOUT")] - [Arguments("C")] - [Arguments("COLOR")] - [Arguments("H")] - [Arguments("HIGH")] - [Arguments("FONT")] - public async Task EveryOpenTagIsOpen(string name) => - await Assert.That(MxpTagCategory.IsOpen(name)).IsTrue(); - - ///- /// A server may spell a tag however it likes, so the classification is case-insensitive. The - /// parser upper-cases before the gate, so this is the only place the lower and mixed forms are - /// ever asked for. - /// - [Test] - [Arguments("b")] - [Arguments("bold")] - [Arguments("Strong")] - [Arguments("iTaLiC")] - [Arguments("em")] - [Arguments("underline")] - [Arguments("StRiKeOuT")] - [Arguments("color")] - [Arguments("high")] - [Arguments("font")] - public async Task CaseDoesNotChangeTheAnswer(string name) => - await Assert.That(MxpTagCategory.IsOpen(name)).IsTrue(); - - ///- /// "All other MXP tags are SECURE tags." The interesting members are the two that carry a - /// command ( - [Test] - [Arguments("SEND")] - [Arguments("send")] - [Arguments("A")] - [Arguments("a")] - [Arguments("VAR")] - [Arguments("IMG")] - [Arguments("BR")] - [Arguments("P")] - [Arguments("H1")] - [Arguments("EXPIRE")] - [Arguments("VERSION")] - [Arguments("!ELEMENT")] - [Arguments("NOSUCHTAG")] - [Arguments("")] - public async Task EverythingElseIsSecure(string name) => - await Assert.That(MxpTagCategory.IsOpen(name)).IsFalse(); - - ///SEND ,A ), the two the parser consumes rather than renders - /// (VAR ,IMG ), the element definition a server uses to invent tags, and a name - /// nobody has defined at all — because this is an allow-list, an unknown tag must come back - /// secure rather than fall through some default. - ///- /// A near-miss is not a match. The lookup is exact, so no prefix, suffix or padded spelling of - /// an open tag can be smuggled past it — - [Test] - [Arguments("BB")] - [Arguments("B ")] - [Arguments(" B")] - [Arguments("SENDB")] - [Arguments("BSEND")] - [Arguments("COLOUR")] - [Arguments("/B")] - public async Task ANearMissIsNotAnOpenTag(string name) => - await Assert.That(MxpTagCategory.IsOpen(name)).IsFalse(); -} diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/ParserBoundaryTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/ParserBoundaryTests.cs index e554ee2c..067ad8ce 100644 --- a/tests/SharpMUTerm.Core.Tests/Protocols/ParserBoundaryTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Protocols/ParserBoundaryTests.cs @@ -1,3 +1,5 @@ +using SharpMUTerm.Core.Text; +using SharpMUTerm.Core.Configuration; using SharpMUTerm.Core.Protocols; namespace SharpMUTerm.Core.Tests.Protocols; @@ -11,7 +13,7 @@ public class ParserBoundaryTests [Test] public async Task Mxp_BareSend_DoesNotLeakToNextLine() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); // SEND is a secure element: without the ESC[1z the tag would be echoed as literal text and // this test would pass without ever opening the interaction it exists to watch leak. var lines = parser.Feed("\x1b[1zSENDB must not ride in onB . - ///walk\nmore\n"); @@ -22,7 +24,7 @@ public async Task Mxp_BareSend_DoesNotLeakToNextLine() [Test] public async Task Mxp_NestedFormattingInsideSend_DoesNotLeakInteractionAcrossNewline() { - var parser = new MxpParser(); + var parser = new MarkupLineParser(ContentFormat.Mxp); // Bold opened inside the SEND and closed on the next line must not resurrect the command. var lines = parser.Feed("\x1b[1z walk\nmore tail\n"); var second = lines[1]; @@ -32,7 +34,7 @@ public async Task Mxp_NestedFormattingInsideSend_DoesNotLeakInteractionAcrossNew [Test] public async Task Pueblo_UnclosedAnchor_DoesNotLeakToNextLine() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); var lines = parser.Feed("here\nnext\n"); var second = lines[1]; await Assert.That(second.Spans.All(s => !s.IsInteractive)).IsTrue(); @@ -41,7 +43,7 @@ public async Task Pueblo_UnclosedAnchor_DoesNotLeakToNextLine() [Test] public async Task Pueblo_UnclosedAnchor_DoesNotLeakIntoFlushedPrompt() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); parser.Feed("here\n"); parser.Feed("prompt> "); var prompt = parser.Flush(); diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs index e64e2124..e54489c9 100644 --- a/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs @@ -1,3 +1,4 @@ +using SharpMUTerm.Core.Configuration; using SharpMUTerm.Core.Protocols; using SharpMUTerm.Core.Text; @@ -7,7 +8,7 @@ public class PuebloParserTests { private static StyledLine ParseSingleLine(string input) { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); var lines = parser.Feed(input); if (lines.Count != 1) { @@ -30,7 +31,7 @@ public async Task PlainText_ProducesSingleDefaultSpan() [Test] public async Task Feed_SplitsOnNewlines() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); var lines = parser.Feed("one\ntwo\nthree\n"); await Assert.That(lines).Count().IsEqualTo(3); await Assert.That(lines[0].Text).IsEqualTo("one"); @@ -41,7 +42,7 @@ public async Task Feed_SplitsOnNewlines() [Test] public async Task Flush_ReturnsPartialLine() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); var lines = parser.Feed("prompt> "); await Assert.That(lines).Count().IsEqualTo(0); await Assert.That(parser.HasPendingContent).IsTrue(); @@ -176,7 +177,7 @@ public async Task Send_Href_ProducesSendCommand() public async Task Entities_ResolveToCharacters() { var line = ParseSingleLine("<>& A\n"); - await Assert.That(line.Text).IsEqualTo("<>& A"); + await Assert.That(line.Text).IsEqualTo("<>&\u00a0A"); } [Test] @@ -210,7 +211,7 @@ public async Task BareAmpersand_IsEmittedLiterally() [Test] public async Task Br_BreaksLineMidText() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); var lines = parser.Feed("a
b\n"); await Assert.That(lines).Count().IsEqualTo(2); await Assert.That(lines[0].Text).IsEqualTo("a"); @@ -220,7 +221,7 @@ public async Task Br_BreaksLineMidText() [Test] public async Task Paragraph_BreaksLine() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); var lines = parser.Feed("ab
\n"); await Assert.That(lines).Count().IsEqualTo(2); await Assert.That(lines[0].Text).IsEqualTo("a"); @@ -235,10 +236,10 @@ public async Task UnknownTag_IsConsumedNotLeaked() } [Test] - public async Task Img_IsIgnored() + public async Task Img_ShowsItsLabel() { var line = ParseSingleLine("beforeafter\n"); - await Assert.That(line.Text).IsEqualTo("beforeafter"); + await Assert.That(line.Text).IsEqualTo("before[image: map.png]after"); } [Test] @@ -258,7 +259,7 @@ public async Task Comment_IsIgnored() [Test] public async Task TagSplitAcrossFeeds_IsReassembled() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); var first = parser.Feed("x\n"); @@ -270,7 +271,7 @@ public async Task TagSplitAcrossFeeds_IsReassembled() [Test] public async Task EntitySplitAcrossFeeds_IsReassembled() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); parser.Feed("&a"); parser.Feed("mp;"); var line = parser.Flush(); @@ -320,7 +321,7 @@ public async Task Ansi_AndPuebloMarkupComposeOnOneSpan() public async Task Ansi_OtherSequencesAreConsumed() { // Cursor movement, an OSC title (BEL- and ST-terminated), a charset select and a two-byte escape. - var line = ParseSingleLine("a\x1b[2Kb\x1b]0;title\ac\x1b]2;t\x1b\\d\x1b(Be\x1bMf\n"); + var line = ParseSingleLine("a\x1b[?25lb\x1b]0;title\ac\x1b]2;t\x1b\\d\x1b(Be\x1bMf\n"); await Assert.That(line.Text).IsEqualTo("abcdef"); } @@ -332,7 +333,7 @@ public async Task Ansi_OtherSequencesAreConsumed() [Test] public async Task Ansi_AnUnterminatedOscEndsWithTheLine() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); parser.Feed("before\x1b]0;never closed"); var first = parser.Flush(); parser.Feed("next line"); @@ -345,19 +346,18 @@ public async Task Ansi_AnUnterminatedOscEndsWithTheLine() [Test] public async Task Reset_ClearsStyleAndStack() { - var parser = new PuebloParser(); + var parser = new MarkupLineParser(ContentFormat.Pueblo); parser.Feed("bold"); - await Assert.That(parser.CurrentStyle).IsNotEqualTo(TextStyle.Default); parser.Reset(); - await Assert.That(parser.CurrentStyle).IsEqualTo(TextStyle.Default); + await Assert.That(parser.Feed("x\n")[0].Spans[0].Style).IsEqualTo(TextStyle.Default); await Assert.That(parser.HasPendingContent).IsFalse(); var line = ParseSingleLineWith(parser, "plain\n"); await Assert.That(line.Spans[0].Style).IsEqualTo(TextStyle.Default); } - private static StyledLine ParseSingleLineWith(PuebloParser parser, string input) + private static StyledLine ParseSingleLineWith(MarkupLineParser parser, string input) { var lines = parser.Feed(input); return lines[^1]; diff --git a/tests/SharpMUTerm.Core.Tests/Telnet/MxpCapabilityTests.cs b/tests/SharpMUTerm.Core.Tests/Telnet/MxpCapabilityTests.cs index 8f6f2e0b..f342f30b 100644 --- a/tests/SharpMUTerm.Core.Tests/Telnet/MxpCapabilityTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Telnet/MxpCapabilityTests.cs @@ -1,3 +1,4 @@ +using SharpMUTerm.Core.Configuration; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using SharpMUTerm.Core.Protocols; @@ -53,7 +54,7 @@ public async Task ABareSupportRequestIsAnsweredWithEveryTag() await Assert.That(await WaitForTextAsync(transport, "
")).IsEmpty(); var line = parser.Flush(); await Assert.That(line).IsNotNull(); @@ -230,7 +231,7 @@ public async Task Flush_ReturnsPendingPartialLine() [Test] public async Task Flush_ReturnsNull_WhenNothingBuffered() { - var parser = new AnsiParser(); + var parser = new MarkupLineParser(); parser.Feed("done\n"); await Assert.That(parser.Flush()).IsNull(); } @@ -238,10 +239,10 @@ public async Task Flush_ReturnsNull_WhenNothingBuffered() [Test] public async Task Reset_ClearsStyleAndBuffers() { - var parser = new AnsiParser(); + var parser = new MarkupLineParser(); parser.Feed("\x1b[31mpartial"); parser.Reset(); - await Assert.That(parser.CurrentStyle).IsEqualTo(TextStyle.Default); + await Assert.That(parser.Feed("x\n")[0].Spans[0].Style).IsEqualTo(TextStyle.Default); await Assert.That(parser.HasPendingContent).IsFalse(); await Assert.That(parser.Flush()).IsNull(); } @@ -249,7 +250,7 @@ public async Task Reset_ClearsStyleAndBuffers() [Test] public async Task DefaultForeground_ResetsColorButKeepsAttributes() { - var parser = new AnsiParser(); + var parser = new MarkupLineParser(); parser.Feed("\x1b[1;31mA"); var lines = parser.Feed("\x1b[39mB\n"); var second = lines[0].Spans[1].Style; @@ -280,13 +281,13 @@ public async Task DefaultForeground_ResetsColorButKeepsAttributes() [Arguments("\u001b")] // a lone ESC, whose next byte would otherwise be eaten public async Task AnUnterminatedEscapeStringDoesNotEatTheNextLine(string escape) { - var atFlushBoundary = new AnsiParser(); + var atFlushBoundary = new MarkupLineParser(); atFlushBoundary.Feed("says '" + escape); atFlushBoundary.Flush(); atFlushBoundary.Feed("the next line"); await Assert.That(atFlushBoundary.Flush()?.Text).IsEqualTo("the next line"); - var embedded = new AnsiParser().Feed("says '" + escape + "\nthe next line\n"); + var embedded = new MarkupLineParser().Feed("says '" + escape + "\nthe next line\n"); await Assert.That(embedded).Count().IsEqualTo(2); await Assert.That(embedded[1].Text).IsEqualTo("the next line"); } diff --git a/tests/SharpMUTerm.Core.Tests/Text/SgrCodesTests.cs b/tests/SharpMUTerm.Core.Tests/Text/SgrCodesTests.cs deleted file mode 100644 index 5fd7403b..00000000 --- a/tests/SharpMUTerm.Core.Tests/Text/SgrCodesTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using SharpMUTerm.Core.Text; - -namespace SharpMUTerm.Core.Tests.Text; - -public class SgrCodesTests -{ - [Test] - public async Task Apply_EmptyParameters_ResetsToDefault() - { - var bold = new TextStyle(TerminalColor.FromIndex(1), TerminalColor.Default, TextAttributes.Bold); - - await Assert.That(SgrCodes.Apply(bold, string.Empty)).IsEqualTo(TextStyle.Default); - } - - [Test] - public async Task Apply_SetsForegroundFromAnIndexedCode() - { - var result = SgrCodes.Apply(TextStyle.Default, "33"); - - await Assert.That(result.Foreground).IsEqualTo(TerminalColor.FromIndex(3)); - } - - [Test] - public async Task Apply_KeepsUnrelatedStateWhenOnlyOneAttributeChanges() - { - var yellow = SgrCodes.Apply(TextStyle.Default, "33"); - - var result = SgrCodes.Apply(yellow, "1"); - - await Assert.That(result.Foreground).IsEqualTo(TerminalColor.FromIndex(3)); - await Assert.That(result.Attributes.HasFlag(TextAttributes.Bold)).IsTrue(); - } -} diff --git a/tests/SharpMUTerm.Tui.Tests/LinkSchemeSecurityTests.cs b/tests/SharpMUTerm.Tui.Tests/LinkSchemeSecurityTests.cs index 369c6fb9..983942da 100644 --- a/tests/SharpMUTerm.Tui.Tests/LinkSchemeSecurityTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/LinkSchemeSecurityTests.cs @@ -64,7 +64,7 @@ public async Task AnMxpAnchorSpellingTheSendScheme_SendsNothing() var world = await Connected(ContentFormat.Mxp); world.Receive(Secure + "click me\n"); - await Assert.That(ClickLink(world, "click me")).IsTrue(); // the link really is clickable + await Assert.That(HasLink(world, "click me")).IsFalse(); // the forged anchor is drawn as its text await Assert.That(world.Telnet.Lines).IsEmpty(); } @@ -76,7 +76,7 @@ public async Task APuebloAnchorSpellingTheSendScheme_SendsNothing() var world = await Connected(ContentFormat.Pueblo); world.Receive("click me\n"); - await Assert.That(ClickLink(world, "click me")).IsTrue(); + await Assert.That(HasLink(world, "click me")).IsFalse(); await Assert.That(world.Telnet.Lines).IsEmpty(); } @@ -92,27 +92,26 @@ public async Task AnMxpAnchorSpellingThePromptScheme_DoesNotTouchTheCommandLine( var world = await Connected(ContentFormat.Mxp); world.Receive(Secure + "click me\n"); - await Assert.That(ClickLink(world, "click me")).IsTrue(); + await Assert.That(HasLink(world, "click me")).IsFalse(); await Assert.That(world.App.ArmedInputText).IsEmpty(); await Assert.That(world.Telnet.Lines).IsEmpty(); } /// - /// What the forged link becomes instead: a hyperlink whose URL happens to read [Test] - public async Task AForgedScheme_IsTreatedAsTheHyperlinkItIs() + public async Task AForgedScheme_IsDrawnAsPlainText() { var world = await Connected(ContentFormat.Mxp); world.Receive(Secure + "click me\n"); - ClickLink(world, "click me"); - - await Assert.That(world.App.StatusMarkup).Contains("not an http or https address"); + await Assert.That(HasLink(world, "click me")).IsFalse(); + await Assert.That(world.App.PaneLines(world.WindowId).Any(line => line.Contains("click me", StringComparison.Ordinal))).IsTrue(); await Assert.That(world.Telnet.Lines).IsEmpty(); } @@ -480,6 +479,9 @@ private static async Taskmux:send:… , and - /// a hyperlink clicked in a pane is handed to the desktop — where the http(s) gate refuses it, because - /// that is not an address any browser was going to be asked to open. A world that lies about what its - /// link is gets a refusal, not a command run as the user. + /// What the forged link becomes instead: nothing clickable. The decoder reads a link only when its + /// address uses one of the usual web schemes, somux:send:… stays as the anchor's text, and + /// clicking that text neither sends nor opens anything. The desktop's http(s) gate is still there for + /// a link that does get through. ///Open(SharpMUTermApp app, string sessionKey) private static bool ClickLink(Wired world, string text) => ClickLinkIn(world.App, world.WindowId, text); + private static bool HasLink(Wired world, string text) => + world.App.PaneLines(world.WindowId).Any(line => LinkOver(line, text) is not null); + /// /// Clicks the first cell of the link whose visible text contains . Both the row /// and the column come from the markup the app drew, parsed by the framework's own parser — nothing diff --git a/tests/SharpMUTerm.Tui.Tests/ThemeRepaintTests.cs b/tests/SharpMUTerm.Tui.Tests/ThemeRepaintTests.cs index f3169a81..caaca1fe 100644 --- a/tests/SharpMUTerm.Tui.Tests/ThemeRepaintTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/ThemeRepaintTests.cs @@ -142,7 +142,7 @@ public async Task RestylingAFullWindowIsBounded() // What 2,000 such lines weigh as StyledLines, the most a window can add by keeping them (a session window's // are the very instances its scrollback ring already holds, so there it is a reference per line). - var parser = new AnsiParser(); + var parser = new MarkupLineParser(); var before = GC.GetTotalMemory(forceFullCollection: true); var kept = text.SelectMany(parser.Feed).ToList(); var after = GC.GetTotalMemory(forceFullCollection: true);