diff --git a/src/Textamina.Markdig.Tests/TestLinkInline.cs b/src/Textamina.Markdig.Tests/TestLinkInline.cs new file mode 100644 index 00000000..0965e1bf --- /dev/null +++ b/src/Textamina.Markdig.Tests/TestLinkInline.cs @@ -0,0 +1,144 @@ +using NUnit.Framework; +using Textamina.Markdig.Syntax; + +namespace Textamina.Markdig.Tests +{ + [TestFixture] + public class TestLinkInline + { + + [Test] + public void TestLinkSimple() + { + var text = new StringLineGroup("toto tutu"); + string link; + Assert.True(LinkInline.TryParseLink(text, out link)); + Assert.AreEqual("toto", link); + Assert.AreEqual(' ', text.Current); + } + + [Test] + public void TestLinkUrl() + { + var text = new StringLineGroup("http://google.com)"); + string link; + Assert.True(LinkInline.TryParseLink(text, out link)); + Assert.AreEqual("http://google.com", link); + Assert.AreEqual(')', text.Current); + } + + [Test] + public void TestLinkNestedParenthesis() + { + var text = new StringLineGroup("(toto)tutu(tata) nooo"); + string link; + Assert.True(LinkInline.TryParseLink(text, out link)); + Assert.AreEqual("(toto)tutu(tata)", link); + Assert.AreEqual(' ', text.Current); + } + + [Test] + public void TestLinkAlternate() + { + var text = new StringLineGroup(" nooo"); + string link; + Assert.True(LinkInline.TryParseLink(text, out link)); + Assert.AreEqual("toto_tata_tutu", link); + Assert.AreEqual(' ', text.Current); + } + + [Test] + public void TestLinkAlternateInvalid() + { + var text = new StringLineGroup(")A"); + string link; + string title; + Assert.True(LinkInline.TryParseLinkAndTitle(text, out link, out title)); + Assert.AreEqual(string.Empty, link); + Assert.AreEqual(string.Empty, title); + Assert.AreEqual('A', text.Current); + } + + [Test] + public void TestLinkAndTitleEmpty2() + { + var text = new StringLineGroup(@"( <> )A"); + string link; + string title; + Assert.True(LinkInline.TryParseLinkAndTitle(text, out link, out title)); + Assert.AreEqual(string.Empty, link); + Assert.AreEqual(string.Empty, title); + Assert.AreEqual('A', text.Current); + } + + + [Test] + public void TestLinkEmptyWithTitleWithMultipleSpaces() + { + var text = new StringLineGroup(@"( <> 'toto' )A"); + string link; + string title; + Assert.True(LinkInline.TryParseLinkAndTitle(text, out link, out title)); + Assert.AreEqual(string.Empty, link); + Assert.AreEqual("toto", title); + Assert.AreEqual('A', text.Current); + } + + + [Test] + public void TestMultipleLines() + { + var text = new StringLineGroup() + { + new StringLine("("), + new StringLine(" "), + new StringLine(" 'toto' )A") + }; + string link; + string title; + Assert.True(LinkInline.TryParseLinkAndTitle(text, out link, out title)); + Assert.AreEqual("http://google.com", link); + Assert.AreEqual("toto", title); + Assert.AreEqual('A', text.Current); + } + } +} \ No newline at end of file diff --git a/src/Textamina.Markdig.Tests/TestStringLine.cs b/src/Textamina.Markdig.Tests/TestStringLine.cs new file mode 100644 index 00000000..b33bb7b3 --- /dev/null +++ b/src/Textamina.Markdig.Tests/TestStringLine.cs @@ -0,0 +1,110 @@ +using NUnit.Framework; +using System.Collections.Generic; +using System.Text; +using Textamina.Markdig.Syntax; + +namespace Textamina.Markdig.Tests +{ + [TestFixture] + public class TestStringLine + { + // TODO: Add tests for StringLine + // TODO: Add more tests for StringLineGroup + + [Test] + public void TestStringLineGroupSimple() + { + var text = new StringLineGroup() + { + new StringLine("ABC"), + new StringLine("E"), + new StringLine("F") + }; + + var chars = ToString(text); + TextAssert.AreEqual("ABC\nE\nF", chars.ToString()); + } + + private static string ToString(StringLineGroup text) + { + var chars = new StringBuilder(); + while (text.Current != '\0') + { + chars.Append(text.Current); + text.NextChar(); + } + return chars.ToString(); + } + + [Test] + public void TestStringLineGroupSaveAndRestore() + { + var text = new StringLineGroup() + { + new StringLine("ABCD"), + new StringLine("EF"), + }; + + text.NextChar(); // B + + var save = text.Save(); + + text.NextChar(); // C + text.NextChar(); // D + text.NextChar(); // \n + text.NextChar(); + Assert.AreEqual('E', text.Current); + Assert.AreEqual(1, text.LinePosition); + Assert.AreEqual(0, text.ColumnPosition); + + text.Restore(ref save); + Assert.AreEqual('B', text.Current); + Assert.AreEqual(0, text.LinePosition); + Assert.AreEqual(1, text.ColumnPosition); + } + + [Test] + public void TestSkipWhitespaces() + { + var text = new StringLineGroup(" ABC"); + Assert.True(text.SkipWhiteSpaces()); + Assert.False(text.SkipWhiteSpaces()); + Assert.AreEqual('A', text.Current); + } + + [Test] + public void TestStringLineGroupWithModifiedStart() + { + var line1 = new StringLine(" ABC"); + line1.NextChar(); + line1.NextChar(); + + var line2 = new StringLine(" DEF "); + line2.Trim(); + + var text = new StringLineGroup() {line1, line2}; + + var result = ToString(text); + TextAssert.AreEqual("ABC\nDEF", result); + } + + + [Test] + public void TestStringLineGroupWithTrim() + { + var line1 = new StringLine(" ABC "); + line1.NextChar(); + line1.NextChar(); + + var line2 = new StringLine(" DEF "); + + var text = new StringLineGroup() { line1, line2 }; + text.Trim(); + + var result = ToString(text); + TextAssert.AreEqual("ABC \n DEF", result); + } + + + } +} \ No newline at end of file diff --git a/src/Textamina.Markdig.Tests/Textamina.Markdig.Tests.csproj b/src/Textamina.Markdig.Tests/Textamina.Markdig.Tests.csproj index a79be9c7..7aba0cbe 100644 --- a/src/Textamina.Markdig.Tests/Textamina.Markdig.Tests.csproj +++ b/src/Textamina.Markdig.Tests/Textamina.Markdig.Tests.csproj @@ -57,6 +57,8 @@ True Specs.tt + + diff --git a/src/Textamina.Markdig/Formatters/HtmlFormatter.cs b/src/Textamina.Markdig/Formatters/HtmlFormatter.cs index 192e3903..98fb5586 100644 --- a/src/Textamina.Markdig/Formatters/HtmlFormatter.cs +++ b/src/Textamina.Markdig/Formatters/HtmlFormatter.cs @@ -31,6 +31,7 @@ namespace Textamina.Markdig.Formatters [typeof(EscapeInline)] = o => Write((EscapeInline)o), [typeof(LiteralInline)] = o => Write((LiteralInline)o), [typeof(CodeInline)] = o => Write((CodeInline)o), + [typeof(ContainerInline)] = o => Write((ContainerInline)o), }; } @@ -123,6 +124,21 @@ namespace Textamina.Markdig.Formatters writer.WriteConstant(""); } + protected void Write(ContainerInline containerInline) + { + var inline = containerInline.FirstChild; + while (inline != null) + { + Action writerAction; + if (registeredWriters.TryGetValue(inline.GetType(), out writerAction)) + { + writerAction(inline); + } + + inline = inline.NextSibling; + } + } + protected void WriteLeaf(LeafBlock leafBlock, bool writeEndOfLines) { var inline = leafBlock.Inline; diff --git a/src/Textamina.Markdig/Parsing/MarkdownParser.cs b/src/Textamina.Markdig/Parsing/MarkdownParser.cs index aa2942dd..010b9a95 100644 --- a/src/Textamina.Markdig/Parsing/MarkdownParser.cs +++ b/src/Textamina.Markdig/Parsing/MarkdownParser.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Text; +using System.Xml.Serialization; using Textamina.Markdig.Syntax; namespace Textamina.Markdig.Parsing @@ -417,13 +418,15 @@ namespace Textamina.Markdig.Parsing private void ProcessInlines(LeafBlock leafBlock) { var lines = leafBlock.Lines; - lines.Initialize(); - var state = new MatchInlineState(lines) {Builder = new StringBuilder() }; + leafBlock.Inline = new ContainerInline(); + var state = new MatchInlineState(lines) + { + Builder = new StringBuilder(), + Inline = leafBlock.Inline + }; - var opened = new List(); - - Inline previousInline = null; + var previousInline = leafBlock.Inline; while (!lines.IsEndOfLines) { var saveLines = lines.Save(); @@ -460,14 +463,17 @@ namespace Textamina.Markdig.Parsing } } - if (previousInline != state.Inline) + var nextInline = state.Inline; + + if (previousInline != nextInline) { if (previousInline is LeafInline) { - opened.Remove(previousInline); previousInline.Close(state); - previousInline.InsertAfter(state.Inline); - opened.Add(state.Inline); + if (nextInline.Parent == null) + { + previousInline.InsertAfter(nextInline); + } } else if (previousInline != null) { @@ -475,25 +481,23 @@ namespace Textamina.Markdig.Parsing if (container.IsClosed) { - opened.Remove(previousInline); - container.InsertAfter(state.Inline); + container.InsertAfter(nextInline); } else { - container.AppendChild(state.Inline); + container.AppendChild(nextInline); } } - - // Store first inline - if (leafBlock.Inline == null) - { - leafBlock.Inline = state.Inline; - } } - previousInline = state.Inline; + previousInline = nextInline; } + while (previousInline != null) + { + previousInline.Close(state); + previousInline = previousInline.Parent; + } // TODO: Close opened inlines diff --git a/src/Textamina.Markdig/Syntax/Inlines/ContainerInline.cs b/src/Textamina.Markdig/Syntax/Inlines/ContainerInline.cs index c21d39da..76c0328e 100644 --- a/src/Textamina.Markdig/Syntax/Inlines/ContainerInline.cs +++ b/src/Textamina.Markdig/Syntax/Inlines/ContainerInline.cs @@ -4,7 +4,7 @@ using Textamina.Markdig.Parsing; namespace Textamina.Markdig.Syntax { - public abstract class ContainerInline : Inline + public class ContainerInline : Inline { public Inline FirstChild { get; private set; } diff --git a/src/Textamina.Markdig/Syntax/Inlines/LinkInline.cs b/src/Textamina.Markdig/Syntax/Inlines/LinkInline.cs index 82f34e1a..2d6bc6f4 100644 --- a/src/Textamina.Markdig/Syntax/Inlines/LinkInline.cs +++ b/src/Textamina.Markdig/Syntax/Inlines/LinkInline.cs @@ -11,134 +11,144 @@ namespace Textamina.Markdig.Syntax [ThreadStatic] private static readonly StringBuilder TempBuilder = new StringBuilder(); - private class ParserInternal : InlineParser - { - public ParserInternal() - { - FirstChars = new[] {'[', ']', '!'}; - } - - public override bool Match(MatchInlineState state) - { - var lines = state.Lines; - - var c = lines.Current; - - bool isImage = false; - if (c == '!') - { - isImage = true; - c = lines.NextChar(); - if (c != '[') - { - return false; - } - } - - if (c == '[') - { - state.Inline = new LinkDelimiterInline(this) - { - Type = DelimiterType.Open, - IsImage = isImage - }; - return true; - } - else if (c == ']') - { - if (state.Inline != null) - { - LinkDelimiterInline firstParent = null; - foreach (var parent in state.Inline.FindParentOfType()) - { - firstParent = parent; - break; - } - - // This will be matched as a literal - if (firstParent != null) - { - // TODO: continue parsing of ] - - // We have a nested [ ] - // firstParent.Remove(); - // The opening [ will be transformed to a literal followed by all the childrens of the [ - - var literal = new LiteralInline() {Content = firstParent.IsImage ? "![" : "["}; - state.Inline = firstParent.ReplaceBy(literal); - - return true; - } - //else - //{ - // var link = firstParent.IsImage - // ? (LinkInline) new ImageLinkInline() - // : new TextLinkInline(); - - // // 1. Process all delimiters inside firstParent to convert them to inlines - // // 2. Replace firstParent with link, and move all child to this one - - // // TODO: continue parsing of ] - - // if (state.Inline == firstParent) - // { - // state.Inline = link; - // } - //} - } - return false; - - // Match a close tag - } - - - // We don't have an emphasis - return false; - } - - private bool TryParseLinkUrl(MatchInlineState state) - { - - - return false; - } - - - private bool TryParseLinkTitle(MatchInlineState state) - { - - - return false; - } - - } - - - public static bool TryParseLinkTitle(StringLineGroup text, out string title) + public static bool TryParseLinkAndTitle(StringLineGroup text, out string link, out string title) { if (text == null) throw new ArgumentNullException(nameof(text)); - // a sequence of zero or more characters between straight double-quote characters ("), including a " character only if it is backslash-escaped, or - // a sequence of zero or more characters between straight single-quote characters ('), including a ' character only if it is backslash-escaped, or - // a sequence of zero or more characters between matching parentheses ((...)), including a ) character only if it is backslash-escaped. + + // 1. An inline link consists of a link text followed immediately by a left parenthesis (, + // 2. optional whitespace, TODO: specs: is it whitespace or multiple whitespaces? + // 3. an optional link destination, + // 4. an optional link title separated from the link destination by whitespace, + // 5. optional whitespace, TODO: specs: is it whitespace or multiple whitespaces? + // 6. and a right parenthesis ) + bool isValid = false; + var c = text.Current; + link = null; title = null; + // 1. An inline link consists of a link text followed immediately by a left parenthesis (, + if (c == '(') + { + text.NextChar(); + text.SkipWhiteSpaces(); + + if (TryParseLink(text, out link)) + { + var hasWhiteSpaces = text.SkipWhiteSpaces(); + + c = text.Current; + if (c == ')') + { + isValid = true; + } + else if (hasWhiteSpaces) + { + c = text.Current; + if (c == ')') + { + isValid = true; + } + else if (TryParseTitle(text, out title)) + { + text.SkipWhiteSpaces(); + c = text.Current; + + if (c == ')') + { + isValid = true; + } + } + } + } + } + + if (isValid) + { + // Skip ')' + text.NextChar(); + title = title ?? string.Empty; + } + + return isValid; + } + + public static bool TryParseTitle(StringLineGroup text, out string title) + { + if (text == null) throw new ArgumentNullException(nameof(text)); + + bool isValid = false; + var buffer = TempBuilder; + buffer.Clear(); + + // a sequence of zero or more characters between straight double-quote characters ("), including a " character only if it is backslash-escaped, or + // a sequence of zero or more characters between straight single-quote characters ('), including a ' character only if it is backslash-escaped, or var c = text.Current; if (c == '\'' || c == '"') { - + var quote = c; + bool hasEscape = false; + + while (true) + { + c = text.NextChar(); + + if (c == '\0') + { + break; + } + + if (c == quote) + { + if (hasEscape) + { + buffer.Append(quote); + hasEscape = false; + continue; + } + + // Skip last quote + text.NextChar(); + isValid = true; + break; + } + + if (hasEscape) + { + buffer.Append('\\'); + } + + if (c == '\\') + { + hasEscape = true; + continue; + } + + hasEscape = false; + + buffer.Append(c); + } + } + else + { + // a sequence of zero or more characters between matching parentheses ((...)), including a ) character only if it is backslash-escaped. + // TODO + + isValid = true; } - return false; + title = isValid ? buffer.ToString() : null; + buffer.Clear(); + return isValid; } - public static bool TryParseLinkDestination(StringLineGroup text, out string link) + public static bool TryParseLink(StringLineGroup text, out string link) { if (text == null) throw new ArgumentNullException(nameof(text)); - var destination = TempBuilder; - destination.Clear(); - link = null; + bool isValid = false; + var buffer = TempBuilder; + buffer.Clear(); var c = text.Current; @@ -152,9 +162,9 @@ namespace Textamina.Markdig.Syntax c = text.NextChar(); if (!nextEscape && c == '>') { - link = destination.ToString(); - destination.Clear(); - return true; + text.NextChar(); + isValid = true; + break; } if (!nextEscape && c == '<') @@ -175,9 +185,11 @@ namespace Textamina.Markdig.Syntax break; } - destination.Append(c); + buffer.Append(c); } while (c != '\0'); + + link = isValid ? buffer.ToString() : null; } else { @@ -187,10 +199,8 @@ namespace Textamina.Markdig.Syntax // parentheses. bool isEscaped = false; int openedParent = 0; - do + while (c != '\0') { - c = text.NextChar(); - // Match opening and closing parenthesis if (c == '(') { @@ -211,11 +221,17 @@ namespace Textamina.Markdig.Syntax openedParent--; if (openedParent < 0) { + isValid = true; break; } } } + if (isEscaped) + { + buffer.Append('\\'); + } + // If we have an escape if (c == '\\') { @@ -225,26 +241,134 @@ namespace Textamina.Markdig.Syntax isEscaped = false; - var isSpace = Utility.IsSpaceOrTab(c); - if (isSpace || Utility.IsControl(c)) // TODO: specs unclear. space is strict or relaxed? (includes tabs?) + if (Utility.IsSpaceOrTab(c) || Utility.IsControl(c)) // TODO: specs unclear. space is strict or relaxed? (includes tabs?) { - if (isSpace && destination.Length > 0) - { - link = destination.ToString(); - destination.Clear(); - return true; - } + isValid = true; break; } - destination.Append(c); + buffer.Append(c); - } while (c != '\0'); + c = text.NextChar(); + } + + isValid = isValid && buffer.Length > 0; + link = isValid ? buffer.ToString() : null; + } + + buffer.Clear(); + return isValid; + } + + private class ParserInternal : InlineParser + { + public ParserInternal() + { + FirstChars = new[] {'[', ']', '!'}; + } + + public override bool Match(MatchInlineState state) + { + var text = state.Lines; + + var c = text.Current; + + bool isImage = false; + if (c == '!') + { + isImage = true; + c = text.NextChar(); + if (c != '[') + { + return false; + } + } + + if (c == '[') + { + state.Inline = new LinkDelimiterInline(this) + { + Type = DelimiterType.Open, + IsImage = isImage + }; + return true; + } + + if (c == ']') + { + if (state.Inline != null) + { + return TryParseEndOfLinkOrImage(ref state.Inline, text); + } + return false; + // Match a close tag + } + + // We don't have an emphasis + return false; + } + + private bool TryParseEndOfLinkOrImage(ref Inline current, StringLineGroup text) + { + LinkDelimiterInline firstParent = null; + foreach (var parent in current.FindParentOfType()) + { + firstParent = parent; + break; + } + + // This will be matched as a literal + if (firstParent != null) + { + // TODO: continue parsing of ] + + var savePoint = text.Save(); + + var link = string.Empty; + var title = string.Empty; + if (text.Current == '(') + { + if (TryParseLinkAndTitle(text, out link, out title)) + { + // Inline Link + } + else + { + text.Restore(ref savePoint); + } + } + + + + + } + else { + // We have a nested [ ] + // firstParent.Remove(); + // The opening [ will be transformed to a literal followed by all the childrens of the [ + + var literal = new LiteralInline() { Content = firstParent.IsImage ? "![" : "[" }; + current = firstParent.ReplaceBy(literal); + + return true; + } + + + + + + + return false; + } + + + private bool TryParseLinkTitle(MatchInlineState state) + { + + + return false; } - // Clear the StringBuilder even after in order to avoid storing things around after using it - destination.Clear(); - return false; } } } \ No newline at end of file diff --git a/src/Textamina.Markdig/Syntax/ParagraphBlock.cs b/src/Textamina.Markdig/Syntax/ParagraphBlock.cs index 8bedf3b3..42fcf337 100644 --- a/src/Textamina.Markdig/Syntax/ParagraphBlock.cs +++ b/src/Textamina.Markdig/Syntax/ParagraphBlock.cs @@ -107,6 +107,25 @@ namespace Textamina.Markdig.Syntax return MatchLineResult.Continue; } + + public override void Close(Block block) + { + var paragraph = block as ParagraphBlock; + if (paragraph != null) + { + var lines = paragraph.Lines; + var lineCount = lines.Count; + for (int i = 0; i < lineCount; i++) + { + var line = lines[i]; + line.TrimStart(); + } + if (lineCount > 0) + { + lines[lineCount - 1].TrimEnd(); + } + } + } } } } \ No newline at end of file diff --git a/src/Textamina.Markdig/Syntax/StringLine.cs b/src/Textamina.Markdig/Syntax/StringLine.cs index 8f885106..2dfc2fbd 100644 --- a/src/Textamina.Markdig/Syntax/StringLine.cs +++ b/src/Textamina.Markdig/Syntax/StringLine.cs @@ -182,6 +182,34 @@ namespace Textamina.Markdig.Syntax return new StringSlice(Text, Start, End); } + public void TrimStart() + { + // Strip leading spaces + var c = Current; + while (Utility.IsSpace(c)) + { + c = NextChar(); + } + } + + public void TrimEnd() + { + for (int i = End; i >= Start; i--) + { + End = i; + if (!Utility.IsSpace(this[i])) + { + break; + } + } + } + + public void Trim() + { + TrimStart(); + TrimEnd(); + } + public override string ToString() { return Start <= End ? Text.Substring(Start, End - Start + 1) : string.Empty; diff --git a/src/Textamina.Markdig/Syntax/StringLineGroup.cs b/src/Textamina.Markdig/Syntax/StringLineGroup.cs index 7735942d..9d6ce6e1 100644 --- a/src/Textamina.Markdig/Syntax/StringLineGroup.cs +++ b/src/Textamina.Markdig/Syntax/StringLineGroup.cs @@ -1,13 +1,24 @@ using System; -using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Text; using Textamina.Markdig.Parsing; namespace Textamina.Markdig.Syntax { - public class StringLineGroup : List + public class StringLineGroup : Collection { private StringLine currentLine; + public StringLineGroup() + { + } + + public StringLineGroup(string text) + { + if (text == null) throw new ArgumentNullException(nameof(text)); + Add(new StringLine(text)); + } + public int LinePosition { get; private set; } public int ColumnPosition { get; private set; } @@ -18,13 +29,50 @@ namespace Textamina.Markdig.Syntax public char PreviousChar2 { get; private set; } - internal void Initialize() + public void Reset() { ColumnPosition = -1; currentLine = Count > 0 ? this[0] : null; + if (currentLine != null) + { + ColumnPosition = currentLine.Start - 1; + } NextChar(); } + protected override void ClearItems() + { + base.ClearItems(); + Reset(); + } + + protected override void InsertItem(int index, StringLine item) + { + base.InsertItem(index, item); + if (index == 0) + { + Reset(); + } + } + + protected override void SetItem(int index, StringLine item) + { + base.SetItem(index, item); + if (index == 0) + { + Reset(); + } + } + + protected override void RemoveItem(int index) + { + base.RemoveItem(index); + if (index == 0) + { + Reset(); + } + } + public bool IsEndOfLines => Current == '\0'; public State Save() @@ -55,9 +103,13 @@ namespace Textamina.Markdig.Syntax } else { - ColumnPosition = -1; LinePosition++; currentLine = LinePosition < Count ? this[LinePosition] : null; + ColumnPosition = -1; + if (currentLine != null) + { + ColumnPosition = currentLine.Start - 1; + } Current = currentLine != null ? '\n' : '\0'; } } @@ -69,6 +121,17 @@ namespace Textamina.Markdig.Syntax return Current; } + public bool SkipWhiteSpaces() + { + bool hasWhitespaces = false; + while (Utility.IsWhiteSpace(Current)) + { + NextChar(); + hasWhitespaces = true; + } + return hasWhitespaces; + } + public void TrimStart() { if (Count == 0) @@ -77,11 +140,8 @@ namespace Textamina.Markdig.Syntax } // Strip leading and - var liner = this[0]; - while (Utility.IsSpace(liner.Current)) - { - liner.NextChar(); - } + this[0].TrimStart(); + Reset(); } public void TrimEnd() @@ -91,15 +151,8 @@ namespace Textamina.Markdig.Syntax return; } - var liner = this[Count - 1]; - for (int i = liner.End; i >= liner.Start; i--) - { - liner.End = i; - if (!Utility.IsSpace(liner[i])) - { - break; - } - } + this[Count - 1].TrimEnd(); + Reset(); } public void Trim() @@ -108,6 +161,21 @@ namespace Textamina.Markdig.Syntax TrimEnd(); } + public override string ToString() + { + var stringBuilder = new StringBuilder(); + bool firstLine = true; + foreach (var line in this) + { + if (!firstLine) + { + stringBuilder.Append('\n'); + } + stringBuilder.Append(line); + firstLine = false; + } + return stringBuilder.ToString(); + } public class State {