This question is a follow up to my previous one which can be found here. The user dfhwze suggested me to look into compiler construction and recommended me to write a lexer and a parser that would process the input step by step. I am very grateful that he pointed me into this direction, because I have the feeling everything is much more robust now. As this is my first time implementing a lexer and a parser I am convinced there are still things that can be optimized a lot.
A few things things that come to my mind:
- Are my naming conventions fine? Are all identifiers self-descriptive?
- Can I abstract the project more? I'd like it to be as flexible as possible.
- Are there performance optimizations that can be made?
Notes:
- Comma separated lists are intentionally processed as one argument.
To test the code run the unit tests (xUnit).
CommandLineLexer.cs
public class CommandLineLexer
{
/// <summary>
/// To read a stream if characters
/// </summary>
private readonly TextReader _reader;
/// <summary>
/// The current token that is processed
/// </summary>
private CommandLineToken? _currentToken;
/// <summary>
/// Create a new lexer for an incoming character stream
/// </summary>
/// <param name="reader">The text reader that processes the data</param>
public CommandLineLexer(TextReader reader)
{
_reader = reader;
}
/// <summary>
/// Gets the next character in the stream
/// </summary>
/// <returns>Read the next character</returns>
private char ReadCharacter()
{
char c = (char) _reader.Read();
return c;
}
/// <summary>
/// Reads next CommandLineToken
/// </summary>
/// <returns>The next lexed token</returns>
public CommandLineToken Next()
{
var nextToken = Peek();
_currentToken = null;
return nextToken;
}
/// <summary>
/// Check next token but doesn't read it yet
/// </summary>
/// <returns>The next token</returns>
public CommandLineToken Peek()
{
if (_currentToken == null)
_currentToken = ReadNextToken();
return _currentToken.Value;
}
/// <summary>
/// Verifies if there are more character is the inputstream
/// </summary>
/// <returns>true if there are more characters, false if end of inputstream</returns>
public bool HasNext()
{
if (_currentToken == null)
{
SkipWhitespaces();
return _reader.Peek() != -1;
}
return true;
}
/// <summary>
/// Do not process whitespaces in the input unless they are part of an argument
/// </summary>
private void SkipWhitespaces()
{
while (true)
{
int c = _reader.Peek();
if (c == -1 || !char.IsWhiteSpace((char) c))
break;
ReadCharacter();
}
}
/// <summary>
/// Read the next token
/// </summary>
/// <returns>The next lexed token</returns>
/// <exception cref="EndOfStreamException"></exception>
private CommandLineToken ReadNextToken()
{
SkipWhitespaces();
int peakedChar = _reader.Peek();
if (peakedChar == -1)
throw new EndOfStreamException(nameof(_reader));
char character = (char) peakedChar;
// Parsing Logic
switch (character)
{
case '-': return ReadSwitch();
case '"': return ReadQuotedArg();
case ',': return ReadCommaSeparator();
default:
return ReadArg();
}
}
/// <summary>
/// Reads arguments that start and end with a quotionmark
/// </summary>
/// <returns>The lexed argument token</returns>
private CommandLineToken ReadQuotedArg()
{
var stringBuilder = new StringBuilder();
while (true)
{
stringBuilder.Append(ReadCharacter());
int chr = _reader.Peek();
if (chr == -1 || chr == '"')
{
stringBuilder.Append("\"");
ReadCharacter();
break;
}
}
return new CommandLineToken(CommandLineTerminal.Argument, stringBuilder.ToString());
}
/// <summary>
/// Reads a comma separator token
/// </summary>
/// <returns>The lexed comma token</returns>
private CommandLineToken ReadCommaSeparator()
{
return new CommandLineToken(CommandLineTerminal.Comma, ReadCharacter().ToString());
}
/// <summary>
/// Reads an argument token
/// </summary>
/// <returns>The lexed comma token</returns>
private CommandLineToken ReadArg()
{
var stringBuilder = new StringBuilder();
var allowedChars = "abcdefghijklmonopqrstuvxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!:!?.".ToList();
while (true)
{
int chr = _reader.Peek();
if (chr == -1)
break;
if (chr == ',' || chr == ' ')
break;
if (!allowedChars.Contains((char) chr))
throw new FormatException($"Illegal character in argument");
stringBuilder.Append(ReadCharacter());
}
return new CommandLineToken(CommandLineTerminal.Argument, stringBuilder.ToString());
}
/// <summary>
/// Reads an argument token
/// </summary>
/// <returns>The lexed switch token</returns>
private CommandLineToken ReadSwitch()
{
var stringBuilder = new StringBuilder();
var allowedChars = "abcdefghijklmonopqrstuvxyz-".ToList();
while (true)
{
int chr = _reader.Peek();
if (chr == -1 || chr == ' ')
break;
if (!allowedChars.Contains((char) chr))
throw new FormatException($"Illegal character in switch: {(char) chr}");
stringBuilder.Append(ReadCharacter());
}
if (stringBuilder.ToString().All(x => x == '-'))
throw new FormatException("Switch does not have a name");
return new CommandLineToken(CommandLineTerminal.Switch, stringBuilder.ToString());
}
}
CommandLineToken.cs
public struct CommandLineToken
{
public CommandLineTerminal Terminal { get; }
public string Text { get; }
public CommandLineToken(CommandLineTerminal terminal, string text)
{
Terminal = terminal;
Text = text;
}
}
CommandLineTerminal.cs
public enum CommandLineTerminal
{
/// <summary>
/// Switch
/// </summary>
Switch,
/// <summary>
/// Argument of a switch
/// </summary>
Argument,
/// <summary>
/// Separator for a list of arguments
/// </summary>
Comma,
}
CommandLineParser.cs
public class CommandLineParser
{
/* Grammar:
*
* switches <- switch+
* switch <- SWITCH args
* args <- ARGUMENT (COMMA ARGUMENT)*
*/
private readonly CommandLineLexer _lexer;
public CommandLineParser(CommandLineLexer lexer)
{
_lexer = lexer ?? throw new ArgumentNullException(nameof(lexer));
}
public CommandLineParser(TextReader reader)
: this(new CommandLineLexer(reader))
{
}
public CommandLineParser(string input)
: this(new StringReader(input))
{
}
public IEnumerable<CommandLineExpression> ParseAll()
{
var parsed = new List<CommandLineExpression>();
while (_lexer.HasNext())
parsed.Add(Parse());
return parsed;
}
private CommandLineExpression Parse()
{
var @switch = ExpectOneOf(CommandLineTerminal.Switch);
// Switch without args
if (!_lexer.HasNext())
return new CommandLineExpression(@switch.Text, null);
// Verify if there are more args after switch
while (true)
{
var next = _lexer.Peek();
switch (next.Terminal)
{
case CommandLineTerminal.Switch:
break;
case CommandLineTerminal.Argument:
{
var allArgs = ParseAllArgs();
return new CommandLineExpression(@switch.Text, allArgs);
}
default:
throw new FormatException("Invalid character");
}
}
}
private IList<IArgument> ParseAllArgs()
{
var allArgs = new List<IArgument>();
while (true)
{
if (!_lexer.HasNext())
return allArgs;
var next = _lexer.Peek();
switch (next.Terminal)
{
case CommandLineTerminal.Switch:
return allArgs;
case CommandLineTerminal.Argument:
{
// Check if we are dealing with an ArgList
var token = _lexer.Next();
if (!_lexer.HasNext())
{
allArgs.Add(new CommandLineArgument(token.Text));
return allArgs;
}
var next2 = _lexer.Peek();
if (next2.Terminal == CommandLineTerminal.Comma)
{
var argList = ParseArgList(token);
allArgs.Add(new CommandLineArgumentList(argList));
break;
}
// Add arg normally - its not part of a list
allArgs.Add(new CommandLineArgument(token.Text));
break;
}
default:
throw new FormatException("Invalid character");
}
}
}
private List<CommandLineArgument> ParseArgList(CommandLineToken token)
{
bool commaExpected = true;
var argList = new List<CommandLineArgument>() {new CommandLineArgument(token.Text)};
while (true)
{
if (!_lexer.HasNext())
return argList;
var next = _lexer.Peek();
switch (@next.Terminal)
{
case CommandLineTerminal.Switch:
{
return argList; // kk, new swithc starts we are done processing the arglist
}
case CommandLineTerminal.Argument:
{
if (commaExpected)
{
// end of arg list but there is more args that do not belong to the list
return argList;
}
argList.Add(new CommandLineArgument(_lexer.Next().Text));
commaExpected = true;
break;
}
case CommandLineTerminal.Comma:
{
if (commaExpected)
{
commaExpected = false;
// consume comma
_lexer.Next(); // ??
break;
}
throw new FormatException(); // two commas after each other?
}
}
}
}
private CommandLineToken ExpectOneOf(params CommandLineTerminal[] terminals)
{
var token = _lexer.Next();
if (!terminals.Contains(token.Terminal))
throw new FormatException($"Expected {string.Join(",", "terminals")}");
return token;
}
}
CommandLineExpression.cs
public class CommandLineExpression
{
public string Switch { get; }
public IList<IArgument> Args { get; }
public CommandLineExpression(string @switch, IList<IArgument> args)
{
Switch = @switch;
Args = args;
}
// Can this be optimized?
public override bool Equals(object obj)
{
var cmp = obj as CommandLineExpression ?? throw new ArgumentNullException(nameof(obj));
if (Switch != cmp.Switch)
return false;
if (Args == null ^ cmp.Args == null)
return false;
if (Args == null && cmp.Args == null)
return true;
if (Args.Count != cmp.Args.Count)
return false;
for (var index = 0; index < Args.Count; index++)
{
// Verify if both args are arglists
if (Args[index] is CommandLineArgumentList)
{
// Compare args and arglists too
if (cmp.Args[index] is CommandLineArgumentList)
{
// Iterate arg lists of both args
for (var index2 = 0; index2 < ((CommandLineArgumentList) Args[index]).Arg.Count; index2++)
{
var argListItem1 = ((CommandLineArgumentList) Args[index]).Arg[index2];
var argListItem2 = ((CommandLineArgumentList) cmp.Args[index]).Arg[index2];
if (argListItem1.Argument != argListItem2.Argument)
return false;
}
}
else
{
return false;
}
continue;
}
if (cmp.Args[index] is CommandLineArgumentList)
{
// Compare args and arglists too
if (Args[index] is CommandLineArgumentList)
{
// Compare args and arglists too
for (var index2 = 0; index2 < ((CommandLineArgumentList) Args[index]).Arg.Count; index2++)
{
var argListItem1 = ((CommandLineArgumentList) Args[index]).Arg[index2];
var argListItem2 = ((CommandLineArgumentList) cmp.Args[index]).Arg[index2];
if (argListItem1.Argument != argListItem2.Argument)
return false;
}
}
else
{
return false;
}
continue;
}
// If argument is not a list do the normal comparison
var arg = (CommandLineArgument) Args[index];
var arg2 = (CommandLineArgument) cmp.Args[index];
if (arg.Argument != arg2.Argument)
return false;
}
return true;
}
}
CommandLineArgumentList.cs
public class CommandLineArgumentList : IArgument
{
public IList<CommandLineArgument> Arg { get; }
public CommandLineArgumentList(IList<CommandLineArgument> arg)
{
Arg = arg;
}
}
CommandLineArgument.cs
public class CommandLineArgument : IArgument
{
public string Argument { get; }
public CommandLineArgument(string argument)
{
Argument = argument;
}
}
IArgument.cs
public interface IArgument
{
}
Unit tests for verification:
CommandLineParserTest.cs
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Xunit;
namespace TinyCommandLineParser.Core.Tests
{
public class CommandLineParserTest
{
[Fact]
public void ParseSwitchNoArgumentTest()
{
var parser = new CommandLineParser("--verbose");
var actual = parser.ParseAll().ToList()[0];
var expected = new CommandLineExpression("--verbose", null);
Assert.Equal(actual, expected);
}
[Fact]
public void ParseShit()
{
var parser = new CommandLineParser("--test --values file1 file2 --data A,B,C");
var actual = parser.ParseAll().ToList();
var expected = new List<CommandLineExpression>
{
new CommandLineExpression("--verbose", null),
new CommandLineExpression("--values", new List<IArgument>()
{
new CommandLineArgument("file1"),
new CommandLineArgument("file2")
}),
new CommandLineExpression("--data", new List<IArgument>()
{
new CommandLineArgumentList(new List<CommandLineArgument>()
{
new CommandLineArgument("A"),
new CommandLineArgument("B"),
new CommandLineArgument("C")
}),
})
};
Assert.All(actual, x => Assert.Equal(actual, expected));
}
[Fact]
public void ParseSwitchMultipleArgumentTest()
{
var parser = new CommandLineParser("--data file1.txt file2.txt file3.txt");
var actual = parser.ParseAll().ToList();
var expected = new List<CommandLineExpression>
{
new CommandLineExpression("--data", new List<IArgument>()
{
new CommandLineArgument("file1.txt"),
new CommandLineArgument("file2.txt"),
new CommandLineArgument("file3.txt"),
})
};
Assert.All(actual, x => Assert.Equal(actual, expected));
}
[Fact]
public void ParseSwitchesWithArgumentListsTest()
{
var stringBuilder = new StringBuilder();
stringBuilder.Append("--data start.txt file1.txt,file2.txt,file3.txt end.txt ");
stringBuilder.Append("--output-dir \"/home/user/my docs/\"");
stringBuilder.Append("--more-data start2.txt file4.txt,file5.txt end2.txt ");
stringBuilder.Append("--verbose");
var parser = new CommandLineParser(stringBuilder.ToString());
var actual = parser.ParseAll().ToList();
var expected = new List<CommandLineExpression>
{
new CommandLineExpression("--data", new List<IArgument>()
{
new CommandLineArgument("start.txt"),
new CommandLineArgumentList(new List<CommandLineArgument>()
{
new CommandLineArgument("file1.txt"),
new CommandLineArgument("file2.txt"),
new CommandLineArgument("file3.txt")
}),
new CommandLineArgument("end.txt"),
}),
new CommandLineExpression("--output-dir", new List<IArgument>()
{
new CommandLineArgument("\"/home/user/my docs/\"")
}),
new CommandLineExpression("--more-data", new List<IArgument>()
{
new CommandLineArgument("start2.txt"),
new CommandLineArgumentList(new List<CommandLineArgument>()
{
new CommandLineArgument("file4.txt"),
new CommandLineArgument("file5.txt"),
}),
new CommandLineArgument("end2.txt"),
}),
new CommandLineExpression("--verbose", null)
};
Assert.All(actual, x => Assert.Equal(actual, expected));
}
}
}
CommandLineLexerTest.cs
using System;
using System.Collections.Generic;
using System.IO;
using Xunit;
namespace TinyCommandLineParser.Core.Tests
{
public class CommandLineLexerTest
{
[Fact]
public void LexIncorrectlyFormattedSwitchTest()
{
Assert.Throws<FormatException>(() =>
{
var lexer = new CommandLineLexer(new StringReader("--ver´bose"));
lexer.Next();
});
Assert.Throws<FormatException>(() =>
{
var lexer = new CommandLineLexer(new StringReader("--"));
lexer.Next();
});
Assert.Throws<FormatException>(() =>
{
var lexer = new CommandLineLexer(new StringReader("-"));
lexer.Next();
});
}
[Fact]
public void LexQuotedArgTest()
{
var input = "--phrase \"this is a test\" --info \"this is cool\"";
var lexer = new CommandLineLexer(new StringReader(input));
var tokens = new List<CommandLineToken>();
while (lexer.HasNext())
tokens.Add(lexer.Next());
var expected = new List<CommandLineToken>()
{
new CommandLineToken(CommandLineTerminal.Switch, "--phrase"),
new CommandLineToken(CommandLineTerminal.Argument, "\"this is a test\""),
new CommandLineToken(CommandLineTerminal.Switch, "--info"),
new CommandLineToken(CommandLineTerminal.Argument, "\"this is cool\"")
};
Assert.Equal(expected, tokens);
}
[Fact]
public void LexMultipleArgsTest()
{
var input = "--load valueA valueB valueC 0x0600001";
var lexer = new CommandLineLexer(new StringReader(input));
var tokens = new List<CommandLineToken>();
while (lexer.HasNext())
tokens.Add(lexer.Next());
var expected = new List<CommandLineToken>()
{
new CommandLineToken(CommandLineTerminal.Switch, "--load"),
new CommandLineToken(CommandLineTerminal.Argument, "valueA"),
new CommandLineToken(CommandLineTerminal.Argument, "valueB"),
new CommandLineToken(CommandLineTerminal.Argument, "valueC"),
new CommandLineToken(CommandLineTerminal.Argument, "0x0600001")
};
Assert.Equal(expected, tokens);
}
[Fact]
public void LexLongSwitchesTest()
{
var input = "--output-directory --verbose -i -rt";
var lexer = new CommandLineLexer(new StringReader(input));
var tokens = new List<CommandLineToken>();
while (lexer.HasNext())
tokens.Add(lexer.Next());
var expected = new List<CommandLineToken>()
{
new CommandLineToken(CommandLineTerminal.Switch, "--output-directory"),
new CommandLineToken(CommandLineTerminal.Switch, "--verbose"),
new CommandLineToken(CommandLineTerminal.Switch, "-i"),
new CommandLineToken(CommandLineTerminal.Switch, "-rt")
};
Assert.Equal(expected, tokens);
}
[Fact]
public void LexCommaSeparatedArgsTest()
{
var input = "--data here,is,some,random,data,123,\"more stuff\",cool";
var lexer = new CommandLineLexer(new StringReader(input));
var tokens = new List<CommandLineToken>();
while (lexer.HasNext())
tokens.Add(lexer.Next());
var expected = new List<CommandLineToken>()
{
new CommandLineToken(CommandLineTerminal.Switch, "--data"),
new CommandLineToken(CommandLineTerminal.Argument, "here"),
new CommandLineToken(CommandLineTerminal.Comma, ","),
new CommandLineToken(CommandLineTerminal.Argument, "is"),
new CommandLineToken(CommandLineTerminal.Comma, ","),
new CommandLineToken(CommandLineTerminal.Argument, "some"),
new CommandLineToken(CommandLineTerminal.Comma, ","),
new CommandLineToken(CommandLineTerminal.Argument, "random"),
new CommandLineToken(CommandLineTerminal.Comma, ","),
new CommandLineToken(CommandLineTerminal.Argument, "data"),
new CommandLineToken(CommandLineTerminal.Comma, ","),
new CommandLineToken(CommandLineTerminal.Argument, "123"),
new CommandLineToken(CommandLineTerminal.Comma, ","),
new CommandLineToken(CommandLineTerminal.Argument, "\"more stuff\""),
new CommandLineToken(CommandLineTerminal.Comma, ","),
new CommandLineToken(CommandLineTerminal.Argument, "cool"),
};
Assert.Equal(expected, tokens);
}
}
}
Please be nitpicky in the review :)