My goal is to take an input stream (text file), and then parse it into a tree node based on each line's indentation.
My rules are
- Lines can be indented using either tabs or spaces, but the file must only consist of one type of indentation.
- A line can be indicated as a child by having a larger indent than the previous line.
- The number of characters you use to indent is not important, 2,3,4 spaces (or tabs), anything is okay.
- You can specify a sibling node by giving it the same number of indent characters as any previous line.
- When unindenting, you can only unindent back to a previously used indentation level. So you cannot indent from 0 to 4, then indent 4 to 8, and then unindent to 5.
// The class to hold the tree
[DebuggerDisplay("Text = {Text}")]
internal class NestedTextNode
{
public int LineNumber { get; private set; }
public string Text { get; private set; }
public ImmutableArray<NestedTextNode> Children { get; private set; }
public NestedTextNode(int lineNumber, string text)
{
if (string.IsNullOrWhiteSpace(text))
throw new ArgumentException(paramName: nameof(text), message: "Required");
LineNumber = lineNumber;
Text = text;
Children = ImmutableArray.Create<NestedTextNode>();
}
public NestedTextNode AddChild(int lineNumber, string content)
{
var child = new NestedTextNode(lineNumber, content);
Children = Children.Add(child);
return child;
}
}
// The class to parse the contents
internal static class IndentedTextParser
{
private static readonly Regex SplitIndentAndText = new Regex(@"^(\s*)(.+)$", RegexOptions.Compiled);
internal static ImmutableArray<NestedTextNode> Parse(StreamReader reader)
{
ArgumentNullException.ThrowIfNull(reader);
var parentNodes = new Stack<KeyValuePair<int, NestedTextNode>>();
int lineNumber = 0;
int firstIndentedLineNumber = 0;
int previousIndentLevel = -1;
var rootNode = new NestedTextNode(-1, "root");
parentNodes.Push(new(-1, rootNode));
NestedTextNode? currentNode = rootNode;
string? line;
char? indentationChar = null;
while ((line = reader.ReadLine()) is not null)
{
lineNumber++;
if (string.IsNullOrWhiteSpace(line))
continue;
var match = SplitIndentAndText.Match(line);
string indent = match.Groups[1].Value;
string content = match.Groups[2].Value;
int indentLevel = GetIndentLevel(indent, lineNumber, ref indentationChar, ref firstIndentedLineNumber);
if (indentLevel <= previousIndentLevel)
{
if (indentLevel < previousIndentLevel)
EnsureIsAtSiblingLevel(parentNodes, lineNumber, previousIndentLevel, indentLevel);
// New node added to parent to make a sibling
currentNode = parentNodes.Peek().Value.AddChild(lineNumber, content);
}
else if (indentLevel > previousIndentLevel)
{
// New node added to current node to make a child
parentNodes.Push(new(indentLevel, currentNode));
currentNode = currentNode.AddChild(lineNumber, content);
}
previousIndentLevel = indentLevel;
}
return rootNode.Children;
}
private static void EnsureIsAtSiblingLevel(
Stack<KeyValuePair<int, NestedTextNode>> parentNodes,
int lineNumber,
int previousIndentLevel,
int indentLevel)
{
do
{
parentNodes.Pop();
if (parentNodes.Count == 1)
throw new TestCaseException(
$"Line {lineNumber} unindents from indent {previousIndentLevel} to {indentLevel}" +
$" but no previous sibling node was found indented to {indentLevel}");
} while (parentNodes.Peek().Key != indentLevel);
}
private static int GetIndentLevel(
string indentText,
int lineNumber,
ref char? indentationChar,
ref int firstIdentedLineNumber)
{
ArgumentNullException.ThrowIfNull(indentText);
if (indentText.Length > 0)
{
if (indentationChar is null)
{
indentationChar = indentText[0];
firstIdentedLineNumber = lineNumber;
}
string? correctIndentNamePlural = null;
string? incorrectIndentNameSingular = null;
if (indentationChar == ' ' && indentText.Contains('\t'))
{
correctIndentNamePlural = "spaces";
incorrectIndentNameSingular = "tab";
}
else if (indentationChar == '\t' && indentText.Contains(' '))
{
correctIndentNamePlural = "tabs";
incorrectIndentNameSingular = "space";
}
if (correctIndentNamePlural is not null)
throw new TestCaseException(
$"Based the first character on line {firstIdentedLineNumber}" +
$" you should be using {correctIndentNamePlural} for indents" +
$" but found a {incorrectIndentNameSingular} on line {lineNumber}");
}
return indentText.Length;
}
}
Example
# When all players agree on a highest bet, we should progress to post-flop
Arrange
With new game
Minimum bet: 32
Next cards: 2 Clubs, 3 Clubs, 4 Clubs, 5 Clubs, 6 Clubs, 7 Clubs, 8 Clubs, 9 Clubs, 10 Clubs
Players
Player | Opening balance
1 | 1000
2 | 2000
3 | 3000
Act
Player 3: Raise to 64
Player 1: Call
Player 2: Call
Assert
Game has state
Step: Post-flop betting
Highest bet: 64
Current player: 1
Shared cards: 8 Clubs, 9 Clubs, 10 Clubs
Players
Player | Current bet | Status
1 | 64 | In play
2 | 64 | In play
3 | 64 | In play