feat: add NATS config file lexer (port of Go conf/lex.go)

Port the NATS configuration file lexer from Go's conf/lex.go to C#.
The lexer is a state-machine tokenizer that supports the NATS config
format: key-value pairs with =, :, or whitespace separators; nested
maps {}; arrays []; single and double quoted strings with escape
sequences; block strings (); variables $VAR; include directives;
comments (# and //); booleans; integers with size suffixes (kb, mb, gb);
floats; ISO8601 datetimes; and IP addresses.
This commit is contained in:
Joseph Doherty
2026-02-23 04:20:56 -05:00
parent f533bf0945
commit 9fff5709c4
3 changed files with 1737 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
// Port of Go conf/lex.go token types.
namespace NATS.Server.Configuration;
public enum TokenType
{
Error,
Eof,
Key,
String,
Bool,
Integer,
Float,
DateTime,
ArrayStart,
ArrayEnd,
MapStart,
MapEnd,
Variable,
Include,
Comment,
}
public readonly record struct Token(TokenType Type, string Value, int Line, int Position);

View File

@@ -0,0 +1,221 @@
using NATS.Server.Configuration;
namespace NATS.Server.Tests;
public class NatsConfLexerTests
{
[Fact]
public void Lex_SimpleKeyStringValue_ReturnsKeyAndString()
{
var tokens = NatsConfLexer.Tokenize("foo = \"bar\"").ToList();
tokens[0].Type.ShouldBe(TokenType.Key);
tokens[0].Value.ShouldBe("foo");
tokens[1].Type.ShouldBe(TokenType.String);
tokens[1].Value.ShouldBe("bar");
tokens[2].Type.ShouldBe(TokenType.Eof);
}
[Fact]
public void Lex_SingleQuotedString_ReturnsString()
{
var tokens = NatsConfLexer.Tokenize("foo = 'bar'").ToList();
tokens[1].Type.ShouldBe(TokenType.String);
tokens[1].Value.ShouldBe("bar");
}
[Fact]
public void Lex_IntegerValue_ReturnsInteger()
{
var tokens = NatsConfLexer.Tokenize("port = 4222").ToList();
tokens[0].Type.ShouldBe(TokenType.Key);
tokens[0].Value.ShouldBe("port");
tokens[1].Type.ShouldBe(TokenType.Integer);
tokens[1].Value.ShouldBe("4222");
}
[Fact]
public void Lex_IntegerWithSuffix_ReturnsInteger()
{
var tokens = NatsConfLexer.Tokenize("size = 64mb").ToList();
tokens[1].Type.ShouldBe(TokenType.Integer);
tokens[1].Value.ShouldBe("64mb");
}
[Fact]
public void Lex_BooleanValues_ReturnsBool()
{
foreach (var val in new[] { "true", "false", "yes", "no", "on", "off" })
{
var tokens = NatsConfLexer.Tokenize($"flag = {val}").ToList();
tokens[1].Type.ShouldBe(TokenType.Bool);
}
}
[Fact]
public void Lex_FloatValue_ReturnsFloat()
{
var tokens = NatsConfLexer.Tokenize("rate = 2.5").ToList();
tokens[1].Type.ShouldBe(TokenType.Float);
tokens[1].Value.ShouldBe("2.5");
}
[Fact]
public void Lex_NegativeNumber_ReturnsInteger()
{
var tokens = NatsConfLexer.Tokenize("offset = -10").ToList();
tokens[1].Type.ShouldBe(TokenType.Integer);
tokens[1].Value.ShouldBe("-10");
}
[Fact]
public void Lex_DatetimeValue_ReturnsDatetime()
{
var tokens = NatsConfLexer.Tokenize("ts = 2024-01-15T10:30:00Z").ToList();
tokens[1].Type.ShouldBe(TokenType.DateTime);
}
[Fact]
public void Lex_HashComment_IsIgnored()
{
var tokens = NatsConfLexer.Tokenize("# this is a comment\nfoo = 1").ToList();
var keys = tokens.Where(t => t.Type == TokenType.Key).ToList();
keys.Count.ShouldBe(1);
keys[0].Value.ShouldBe("foo");
}
[Fact]
public void Lex_SlashComment_IsIgnored()
{
var tokens = NatsConfLexer.Tokenize("// comment\nfoo = 1").ToList();
var keys = tokens.Where(t => t.Type == TokenType.Key).ToList();
keys.Count.ShouldBe(1);
}
[Fact]
public void Lex_MapBlock_ReturnsMapStartEnd()
{
var tokens = NatsConfLexer.Tokenize("auth { user: admin }").ToList();
tokens[0].Type.ShouldBe(TokenType.Key);
tokens[0].Value.ShouldBe("auth");
tokens[1].Type.ShouldBe(TokenType.MapStart);
tokens[2].Type.ShouldBe(TokenType.Key);
tokens[2].Value.ShouldBe("user");
tokens[3].Type.ShouldBe(TokenType.String);
tokens[3].Value.ShouldBe("admin");
tokens[4].Type.ShouldBe(TokenType.MapEnd);
}
[Fact]
public void Lex_Array_ReturnsArrayStartEnd()
{
var tokens = NatsConfLexer.Tokenize("items = [1, 2, 3]").ToList();
tokens[1].Type.ShouldBe(TokenType.ArrayStart);
tokens[2].Type.ShouldBe(TokenType.Integer);
tokens[2].Value.ShouldBe("1");
tokens[5].Type.ShouldBe(TokenType.ArrayEnd);
}
[Fact]
public void Lex_Variable_ReturnsVariable()
{
var tokens = NatsConfLexer.Tokenize("secret = $MY_VAR").ToList();
tokens[1].Type.ShouldBe(TokenType.Variable);
tokens[1].Value.ShouldBe("MY_VAR");
}
[Fact]
public void Lex_Include_ReturnsInclude()
{
var tokens = NatsConfLexer.Tokenize("include \"auth.conf\"").ToList();
tokens[0].Type.ShouldBe(TokenType.Include);
tokens[0].Value.ShouldBe("auth.conf");
}
[Fact]
public void Lex_EscapeSequences_AreProcessed()
{
var tokens = NatsConfLexer.Tokenize("msg = \"hello\\tworld\\n\"").ToList();
tokens[1].Type.ShouldBe(TokenType.String);
tokens[1].Value.ShouldBe("hello\tworld\n");
}
[Fact]
public void Lex_HexEscape_IsProcessed()
{
var tokens = NatsConfLexer.Tokenize("val = \"\\x41\\x42\"").ToList();
tokens[1].Value.ShouldBe("AB");
}
[Fact]
public void Lex_ColonSeparator_Works()
{
var tokens = NatsConfLexer.Tokenize("foo: bar").ToList();
tokens[0].Type.ShouldBe(TokenType.Key);
tokens[1].Type.ShouldBe(TokenType.String);
}
[Fact]
public void Lex_WhitespaceSeparator_Works()
{
var tokens = NatsConfLexer.Tokenize("foo bar").ToList();
tokens[0].Type.ShouldBe(TokenType.Key);
tokens[1].Type.ShouldBe(TokenType.String);
}
[Fact]
public void Lex_SemicolonTerminator_IsHandled()
{
var tokens = NatsConfLexer.Tokenize("foo = 1; bar = 2").ToList();
var keys = tokens.Where(t => t.Type == TokenType.Key).ToList();
keys.Count.ShouldBe(2);
}
[Fact]
public void Lex_EmptyInput_ReturnsEof()
{
var tokens = NatsConfLexer.Tokenize("").ToList();
tokens.Count.ShouldBe(1);
tokens[0].Type.ShouldBe(TokenType.Eof);
}
[Fact]
public void Lex_BlockString_ReturnsString()
{
var input = "desc (\nthis is\na block\n)\n";
var tokens = NatsConfLexer.Tokenize(input).ToList();
tokens[0].Type.ShouldBe(TokenType.Key);
tokens[1].Type.ShouldBe(TokenType.String);
}
[Fact]
public void Lex_IPAddress_ReturnsString()
{
var tokens = NatsConfLexer.Tokenize("host = 127.0.0.1").ToList();
tokens[1].Type.ShouldBe(TokenType.String);
tokens[1].Value.ShouldBe("127.0.0.1");
}
[Fact]
public void Lex_TrackLineNumbers()
{
var tokens = NatsConfLexer.Tokenize("a = 1\nb = 2\nc = 3").ToList();
tokens[0].Line.ShouldBe(1); // a
tokens[2].Line.ShouldBe(2); // b
tokens[4].Line.ShouldBe(3); // c
}
[Fact]
public void Lex_UnterminatedString_ReturnsError()
{
var tokens = NatsConfLexer.Tokenize("foo = \"unterminated").ToList();
tokens.ShouldContain(t => t.Type == TokenType.Error);
}
[Fact]
public void Lex_StringStartingWithDigit_TreatedAsString()
{
var tokens = NatsConfLexer.Tokenize("foo = 3xyz").ToList();
tokens[1].Type.ShouldBe(TokenType.String);
tokens[1].Value.ShouldBe("3xyz");
}
}