diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c9294e4..db34d03 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -21,6 +21,7 @@ jobs:
7.x
8.x
9.x
+ 10.0.x
- name: Checkout
uses: actions/checkout@v4
diff --git a/.gitignore b/.gitignore
index bd6adb1..78130b3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,7 @@
-#Ignore thumbnails created by windows
+# Ignore thumbnails created by windows
[Tt]humbs.db
-#Ignore files build by Visual Studio
+# Ignore files build by Visual Studio
*.obj
*.exe
*.pdb
@@ -33,5 +33,8 @@ obj/
*.ReSharper.user
[Tt]est[Rr]esult*
-#Ignore Rider/Idea files
+# Ignore benchmark artifacts
+BenchmarkDotNet.Artifacts/
+
+# Ignore Rider/Idea files
.idea/
diff --git a/Directory.Packages.props b/Directory.Packages.props
index c8fe71c..b74cef8 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,15 +3,16 @@
true
+
-
+
-
+
-
-
+
+
-
\ No newline at end of file
+
diff --git a/README.md b/README.md
index 5413317..404ba6f 100644
--- a/README.md
+++ b/README.md
@@ -3,36 +3,37 @@
[](https://github.com/rameel/ramstack.parsing/blob/main/LICENSE)
A fast, lightweight parser combinator library for .NET.
+Build text parsers in C# with typed results and optional error diagnostics.
-## Getting Started
+## Installation
+
+Requires .NET 6 or later.
-To add the `Ramstack.Parsing` [NuGet package](https://www.nuget.org/packages/Ramstack.Parsing) to your project, run:
```shell
dotnet add package Ramstack.Parsing
```
-## Usage
+## Example
-Here's how to define a simple expression parser that parses and evaluates mathematical expressions in one step:
+This parser evaluates arithmetic expressions with parentheses, unary minus, and operator precedence:
```csharp
-public static Parser Calc { get; } = CreateParser();
+using System;
+using Ramstack.Parsing;
+using static Ramstack.Parsing.Parser;
-private static Parser CreateParser()
-{
- // Grammar:
- // ----------------------------------------
- // Start : S Sum $
- // Sum : Product ([+-] S Product)*
- // Product : Unary ([*/] S Unary)*
- // Unary : '-'? S Primary
- // Primary : Parenthesis / Value
- // Parenthesis : '(' S Sum ')' S
- // Value : Number S
- // S : [ \r\n\t]*
+var calc = CreateParser();
+
+Console.WriteLine(calc.Parse("2 + 3 * (4 - 1)")); // 11
+Console.WriteLine(calc.Parse("1 + (")); // (1:6) Expected '-', '(', or number
+static Parser CreateParser()
+{
var sum = Deferred();
- var value = Literal.Number().ThenIgnore(S);
+
+ var value = Literal
+ .Number("number")
+ .ThenIgnore(S);
var parenthesis = sum.Between(
Seq(L('('), S),
@@ -47,11 +48,11 @@ private static Parser CreateParser()
primary
).Do((u, _, d) => u.HasValue ? -d : d);
- var product = unary.Fold(
+ var product = unary.FoldL(
OneOf("*/").ThenIgnore(S),
(l, r, op) => op == '*' ? l * r : l / r);
- sum.Parser = product.Fold(
+ sum.Parser = product.FoldL(
OneOf("+-").ThenIgnore(S),
(l, r, op) => op == '+' ? l + r : l - r);
@@ -59,203 +60,82 @@ private static Parser CreateParser()
}
```
-Use the parser as follows:
+Full grammar: [calculator](https://github.com/rameel/ramstack.parsing/blob/main/samples/CalcExpr/README.md).
+
+### Parsing results
+
+`Parse` returns a result with `Success`, `Value`, and `ErrorMessage` properties.
+Parse errors include the line, column, and expected input:
```csharp
-var result = Calc.Parse(expression);
+var result = calc.Parse("2 + 3 * (4 - 1)");
if (result.Success)
{
- var value = result.Value;
- ...
+ Console.WriteLine(result.Value);
}
else
{
Console.WriteLine(result.ErrorMessage);
-
- //
- // result.ToString() returns the parsed value as a string,
- // or an error message if parsing fails.
- //
- // Console.WriteLine(result);
- //
}
```
-The parser provides detailed error messages when the input does not conform to the specified grammar.
-For example, when parsing the expression `"1 + ("`, the parser expects `'-'`, `'('`, or a `number` after the opening parenthesis.
-The error message clearly indicates this:
-
-```
-(1:6) Expected '-', '(' or number
-```
-
-In addition to the main `Parse` method, there is also a lightweight `bool TryParse(ReadOnlySpan source, out T value)` method.
-This method suppresses diagnostic messages, returning only the parsing success status and the parsed value if successful.
-
-If detailed error messages are not required, using `TryParse` is the preferred choice, as it reduces memory allocations
-and slightly improves performance by avoiding the overhead associated with collecting diagnostic data.
-
-## Documentation
-
-- Documentation can be found in [docs/documentation.md](docs/documentation.md) (Work in Progress).
-- Additional usage samples, see the [samples/Samples](samples) directory.
-
-## Performance
-
-`Ramstack.Parsing` stands out as an exceptionally fast and efficient parsing library, outperforming many alternatives in both speed and memory usage.
-
-### Benchmarks: Regex
-
-Here's a comparison between a regex-based email matcher and an equivalent parser combinator implementation:
+Use `TryParse` when you only need the value and success status.
+It skips diagnostics, so it allocates less and runs faster:
-**Regular Expression:**
```csharp
-[GeneratedRegex(@"^[\w.+-]+@[\w-]+\.\w{2,}$")]
-private static partial Regex EmailRegexGenerated();
-```
-
-**Parser**:
-```csharp
-var parser = Seq(
- Choice(LetterOrDigit, OneOf("_-+.")).OneOrMore(),
- L('@'),
- Choice(LetterOrDigit, L('-')).OneOrMore(),
- L('.'),
- LetterOrDigit.AtLeast(2)
-).Text();
-```
-The library supports nearly full character class definitions from regular expressions, allowing grammars to be written in a similar way
-as it written for regular expression. Optimizations ensure that both definitions are internally normalized to an equivalent representation:
-```csharp
-var parser = Seq(
- Set("\\w+.-").OneOrMore(),
- L('@'),
- Set("\\w-").OneOrMore(),
- L('.'),
- Set("\\w").AtLeast(2)
-).Text();
-```
-
-The benchmark results show that `Ramstack.Parsing` outperforms even highly optimized Regex implementations,
-including compiled and source-generated regexes.
-
-```
-BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3037)
-AMD Ryzen 9 5900X, 1 CPU, 24 logical and 12 physical cores
-.NET SDK 9.0.200-preview.0.25057.12
- [Host] : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX2
- DefaultJob : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX2
-
-
-| Method | Mean | Error | StdDev | Op/s | Gen0 | Allocated |
-|---------------- |------------:|---------:|---------:|-------------:|-------:|----------:|
-| Ramstack | 34.47 ns | 0.073 ns | 0.065 ns | 29,008,779.8 | - | - |
-| Regex | 107.35 ns | 0.125 ns | 0.117 ns | 9,315,658.6 | - | - |
-| Regex:Compiled | 44.63 ns | 0.093 ns | 0.083 ns | 22,408,327.6 | - | - |
-| Regex:Generated | 41.85 ns | 0.282 ns | 0.250 ns | 23,893,269.4 | - | - |
-| Parlot | 295.05 ns | 0.601 ns | 0.533 ns | 3,389,302.6 | 0.0229 | 384 B |
-| Parlot:Compiled | 228.98 ns | 0.446 ns | 0.417 ns | 4,367,155.0 | 0.0091 | 152 B |
-| Pidgin | 1,602.00 ns | 9.016 ns | 8.434 ns | 624,219.5 | 0.0572 | 976 B |
+if (calc.TryParse("2 + 3 * (4 - 1)", out var value))
+{
+ Console.WriteLine(value);
+}
```
-In this specific scenario, the library simply checks for a match against the email pattern, similar to what `Regex.IsMatch` does,
-without extracting the match result itself. Like with regular expressions, this approach avoids unnecessary memory allocations.
-
-### Benchmarks: Expressions
-
-This benchmarks tests 2 expressions:
-- **Small**: `2.5 * 4.7 - 8.1 / 3`
-- **Large**: `((3.14159 * 2.3 * 4) / (1.5 - 0.3) + (2.71828 * 2.5)) * ((8.1 * 3.2 + 4.7) / 2.1 - ((15.7 + 2.3 * 4) / (1.5 - 0.3))) - (((3.5 * 1.41421) * (2.71828 + 3.14159)) / 2)`
+### Parsing notes
-```
-BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3037)
-AMD Ryzen 9 5900X, 1 CPU, 24 logical and 12 physical cores
-.NET SDK 9.0.200-preview.0.25057.12
- [Host] : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX2
- DefaultJob : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX2
-
-
-| Method | Mean | Error | StdDev | Op/s | Gen0 | Allocated |
-|---------------------- |------------:|----------:|----------:|------------:|-------:|----------:|
-| Ramstack:Large | 1,600.2 ns | 9.54 ns | 7.96 ns | 624,925.9 | - | - |
-| Ramstack:Diag:Large | 1,699.6 ns | 3.53 ns | 3.13 ns | 588,379.8 | 0.0057 | 104 B |
-| Parlot:Large | 3,520.1 ns | 11.08 ns | 10.37 ns | 284,081.7 | 0.1984 | 3344 B |
-| Parlot:Compiled:Large | 3,356.7 ns | 7.08 ns | 6.27 ns | 297,910.3 | 0.1984 | 3344 B |
-| Pidgin:Large | 39,197.3 ns | 150.13 ns | 140.43 ns | 25,511.9 | 0.2441 | 4464 B |
-| | | | | | | |
-| Ramstack:Small | 209.5 ns | 0.28 ns | 0.25 ns | 4,772,894.8 | - | - |
-| Ramstack:Diag:Small | 260.6 ns | 0.57 ns | 0.50 ns | 3,838,032.3 | 0.0062 | 104 B |
-| Parlot:Small | 410.7 ns | 1.46 ns | 1.36 ns | 2,434,653.7 | 0.0391 | 656 B |
-| Parlot:Compiled:Small | 385.1 ns | 1.22 ns | 0.95 ns | 2,596,891.9 | 0.0391 | 656 B |
-| Pidgin:Small | 4,322.2 ns | 18.28 ns | 17.10 ns | 231,363.9 | 0.0381 | 736 B |
-```
+- `Parse` and `TryParse` accept strings and `ReadOnlySpan`.
+- `L` matches a literal; `S` matches zero or more whitespace characters. Whitespace is handled explicitly.
+- Add `Eof` to require the entire input. Here, `Between(S, Eof)` allows leading whitespace and rejects trailing input.
+- Build each parser once and reuse it.
-- `Ramstack` diagnostic messages disabled.
-- `Ramstack:Diag` includes diagnostic messages.
-
-Even with diagnostics enabled, `Ramstack.Parsing` outperforms other popular libraries.
+## Performance
-### Benchmarks: JSON
+In this email-matching benchmark, `Ramstack.Parsing` performs about as fast as compiled and source-generated regex,
+with no measured managed allocations.
-This benchmark evaluates JSON parsing performance, including object model construction, across different file sizes.
+The benchmark matches `development.team-2021@example.com` against `\A[\w.+-]+@[\w-]+\.\w{2,}\z`.
+All implementations use the same Unicode character classes and require the end of input.
+This is a simplified pattern, not a full email validator.
-- **Small**: 1.16KB
-- **Medium**: 11KB
-- **Large**: `twitter.json` (617KB) from [serde-rs/json-benchmark](https://github.com/serde-rs/json-benchmark)
+Measured with BenchmarkDotNet on .NET 11 RC1 (Linux, AMD Ryzen 9 5900X).
-```
-BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3037)
-AMD Ryzen 9 5900X, 1 CPU, 24 logical and 12 physical cores
-.NET SDK 9.0.200-preview.0.25057.12
- [Host] : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX2
- DefaultJob : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX2
-
-
-| Method | Input | Mean | Error | StdDev | Op/s | Gen0 | Gen1 | Gen2 | Allocated |
-|----------------- |------- |--------------:|-----------:|-----------:|-----------:|---------:|---------:|---------:|-----------:|
-| Ramstack | big | 1,307.929 us | 12.6067 us | 11.7923 us | 764.57 | 152.3438 | 130.8594 | - | 2498.88 KB |
-| Parlot | big | 3,102.953 us | 9.3441 us | 8.7405 us | 322.27 | 156.2500 | 132.8125 | - | 2554.06 KB |
-| Parlot:Compiled | big | 3,078.931 us | 7.4028 us | 5.7796 us | 324.79 | 156.2500 | 132.8125 | - | 2554.06 KB |
-| Pidgin | big | 16,675.788 us | 40.7050 us | 36.0839 us | 59.97 | 156.2500 | 93.7500 | - | 2570.33 KB |
-| Newtonsoft.Json | big | 3,878.753 us | 18.3372 us | 16.2554 us | 257.81 | 343.7500 | 320.3125 | - | 5638.63 KB |
-| System.Text.Json | big | 1,492.658 us | 10.2347 us | 9.0728 us | 669.95 | 216.7969 | 216.7969 | 216.7969 | 963.67 KB |
-
-| Ramstack | medium | 20.163 us | 0.0968 us | 0.0906 us | 49,595.14 | 2.6245 | 0.2747 | - | 42.94 KB |
-| Parlot | medium | 72.128 us | 0.1193 us | 0.1058 us | 13,864.18 | 2.5635 | 0.2441 | - | 43.09 KB |
-| Parlot:Compiled | medium | 70.931 us | 0.2758 us | 0.2445 us | 14,098.14 | 2.5635 | 0.2441 | - | 43.09 KB |
-| Pidgin | medium | 266.283 us | 0.4172 us | 0.3698 us | 3,755.41 | 2.4414 | - | - | 44.45 KB |
-| Newtonsoft.Json | medium | 52.036 us | 0.4550 us | 0.4257 us | 19,217.47 | 6.0425 | 1.6479 | - | 99.44 KB |
-| System.Text.Json | medium | 24.471 us | 0.0968 us | 0.0756 us | 40,865.15 | 1.0376 | - | - | 17.03 KB |
-
-| Ramstack | small | 3.016 us | 0.0064 us | 0.0053 us | 331,565.28 | 0.3815 | 0.0038 | - | 6.24 KB |
-| Parlot | small | 6.723 us | 0.0313 us | 0.0292 us | 148,732.16 | 0.3891 | - | - | 6.39 KB |
-| Parlot:Compiled | small | 6.661 us | 0.0210 us | 0.0175 us | 150,120.62 | 0.3891 | - | - | 6.39 KB |
-| Pidgin | small | 33.369 us | 0.3909 us | 0.3656 us | 29,968.03 | 0.3662 | - | - | 6.49 KB |
-| Newtonsoft.Json | small | 7.940 us | 0.0226 us | 0.0201 us | 125,951.32 | 1.0529 | 0.0458 | - | 17.26 KB |
-| System.Text.Json | small | 3.477 us | 0.0133 us | 0.0111 us | 287,598.87 | 0.1373 | - | - | 2.25 KB |
-```
+| Method | Mean | Ratio | Allocated |
+|-----------------|----------:|------:|----------:|
+| Ramstack | 34.81 ns | 1.00 | - |
+| Regex | 106.58 ns | 3.06 | - |
+| Regex:Compiled | 38.02 ns | 1.09 | - |
+| Regex:Generated | 38.42 ns | 1.10 | - |
+| Parlot | 160.11 ns | 4.60 | 184 B |
+| Parlot:Compiled | 150.57 ns | 4.32 | 184 B |
+| Pidgin | 202.13 ns | 5.81 | 0–40 B |
-`Ramstack.Parsing` demonstrated superior performance, outperforming other parser libraries by at least 2-3 times,
-and even exceeded specialized JSON parsers such as `Newtonsoft.Json` and `System.Text.Json` in terms of both speed and memory efficiency.
+Pidgin measured 0–40 B across runs.
-### Benchmarked Libraries
+Among parser combinator libraries, `Ramstack.Parsing` is the fastest in this comparison - about 1.7–1.9x faster
+than the next-fastest implementation on the expression and JSON benchmarks.
-1. [x] Parlot 1.3.2
-2. [x] Pidgin 3.3.0
-3. [x] Newtonsoft.Json 13.0.3
-4. [x] System.Text.Json 9.0.0
+See the [full benchmark results](https://github.com/rameel/ramstack.parsing/blob/main/benchmarks/README.md)
+for expression evaluation, JSON parsing, library versions, and reproduction commands.
+Results depend on the grammar, input, runtime, and hardware.
-## Supported Versions
+## Examples
-| | Version |
-|------|------------|
-| .NET | 6, 7, 8, 9 |
+- [Calculator](https://github.com/rameel/ramstack.parsing/tree/main/samples/CalcExpr) — recursive expressions and operator precedence.
+- [JSON](https://github.com/rameel/ramstack.parsing/tree/main/samples/Json) — nested objects and arrays.
+- [TinyC](https://github.com/rameel/ramstack.parsing/tree/main/samples/TinyC) — statements, expressions, and comments.
-## Contributions
+## Contributing
-Bug reports and contributions are welcome.
+Bug reports and pull requests are welcome. See [Contributing](https://github.com/rameel/ramstack.parsing/blob/main/CONTRIBUTING.md).
## License
-This package is released under the **MIT License**.
-See the [LICENSE](https://github.com/rameel/ramstack.parsing/blob/main/LICENSE) file for details.
+[MIT](https://github.com/rameel/ramstack.parsing/blob/main/LICENSE).
diff --git a/benchmarks/EmailBenchmark.cs b/benchmarks/EmailBenchmark.cs
index 1936fea..8e69d7d 100644
--- a/benchmarks/EmailBenchmark.cs
+++ b/benchmarks/EmailBenchmark.cs
@@ -1,6 +1,7 @@
using System.Text.RegularExpressions;
using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Columns;
using Pidgin;
@@ -10,53 +11,81 @@ namespace Ramstack.Parsing.Benchmarks;
[MemoryDiagnoser]
[OperationsPerSecond]
+[HideColumns(Column.Error, Column.StdDev)]
public partial class EmailBenchmark
{
- private static readonly string Email = "development.team-2021@example.com";
+ public const string RegexPattern = @"\A[\w.+-]+@[\w-]+\.\w{2,}\z";
+ private readonly string _emailValue = "development.team-2021@example.com";
- private static readonly Regex EmailRegex = new Regex(@"^[\w._+-]+@[\w-]+\.\w{2,}$");
- private static readonly Regex EmailRegexCompiled = new Regex((@"^[\w._+-]+@[\w-]+\.\w{2,}$"), RegexOptions.Compiled);
+ public static readonly Regex EmailRegex = new Regex(RegexPattern);
+ public static readonly Regex EmailRegexCompiled = new Regex(RegexPattern, RegexOptions.Compiled);
- [GeneratedRegex(@"^[\w._+-]+@[\w-]+\.\w{2,}$")]
+ [GeneratedRegex(RegexPattern)]
private static partial Regex EmailRegexGenerated();
[GlobalSetup]
public void Setup()
{
- if (!EmailRegex.IsMatch(Email)) throw new Exception(nameof(EmailRegex));
- if (!EmailRegexCompiled.IsMatch(Email)) throw new Exception(nameof(EmailRegexCompiled));
- if (!EmailRegexGenerated().IsMatch(Email)) throw new Exception(nameof(EmailRegexGenerated));
- if (!RamstackParsers.EmailParser.TryParse(Email, out _)) throw new Exception("RamstackParsers.EmailParser");
- if (!ParlotParsers.EmailParser.TryParse(Email, out _)) throw new Exception("ParlotParsers.EmailParser");
- if (!ParlotParsers.EmailParserCompiled.TryParse(Email, out _)) throw new Exception("ParlotParsers.EmailParserCompiled");
- PidginParsers.EmailParser.ParseOrThrow(Email);
+ (string Name, Func Match)[] matchers =
+ [
+ ("Ramstack", input => RamstackParsers.EmailParser.TryParse(input, out _)),
+ ("Regex", EmailRegex.IsMatch),
+ ("Regex:Compiled", EmailRegexCompiled.IsMatch),
+ ("Regex:Generated", EmailRegexGenerated().IsMatch),
+ ("Parlot", input => ParlotParsers.EmailParser.TryParse(input, out _)),
+ ("Parlot:Compiled", input => ParlotParsers.EmailParserCompiled.TryParse(input, out _)),
+ ("Pidgin", input => PidginParsers.EmailParser.Parse(input).Success)
+ ];
+ (string Input, bool Expected)[] cases =
+ [
+ (_emailValue, true),
+ ("a_b+c.d-e@host-name.c0", true),
+ ("e\u0301@\u203Fhost.\u203F\u0301", true),
+ ("x@host.12", true),
+ ("", false),
+ ("@host.com", false),
+ ("x@.com", false),
+ ("x@host.c", false),
+ ("x@host.co.uk", false),
+ ("x@host.c-", false),
+ (_emailValue + "!", false),
+ (_emailValue + "\n", false),
+ (_emailValue + "\r\n", false),
+ (" " + _emailValue, false),
+ ("x\u200D@host.com", false)
+ ];
+
+ foreach (var (name, match) in matchers)
+ foreach (var (input, expected) in cases)
+ if (match(input) != expected)
+ throw new InvalidOperationException($"{name}: unexpected email match for {input}");
}
- [Benchmark(Description = "Ramstack")]
+ [Benchmark(Baseline = true, Description = "Ramstack")]
public bool RamstackEmail() =>
- RamstackParsers.EmailVoidParser.TryParse(Email, out _);
+ RamstackParsers.EmailParser.TryParse(_emailValue, out _);
[Benchmark(Description = "Regex")]
public bool RegexEmail() =>
- EmailRegex.IsMatch(Email);
+ EmailRegex.IsMatch(_emailValue);
[Benchmark(Description = "Regex:Compiled")]
public bool RegexEmailCompiled() =>
- EmailRegexCompiled.IsMatch(Email);
+ EmailRegexCompiled.IsMatch(_emailValue);
[Benchmark(Description = "Regex:Generated")]
public bool RegexEmailGenerated() =>
- EmailRegexGenerated().IsMatch(Email);
+ EmailRegexGenerated().IsMatch(_emailValue);
[Benchmark(Description = "Parlot")]
public bool ParlotEmail() =>
- ParlotParsers.EmailParser.TryParse(Email, out _);
+ ParlotParsers.EmailParser.TryParse(_emailValue, out _);
[Benchmark(Description = "Parlot:Compiled")]
public bool ParlotEmailCompiled() =>
- ParlotParsers.EmailParserCompiled.TryParse(Email, out _);
+ ParlotParsers.EmailParserCompiled.TryParse(_emailValue, out _);
[Benchmark(Description = "Pidgin")]
public bool PidginEmail() =>
- PidginParsers.EmailParser.Parse(Email).Success;
+ PidginParsers.EmailParser.Parse(_emailValue).Success;
}
diff --git a/benchmarks/ExpressionBenchmark.cs b/benchmarks/ExpressionBenchmark.cs
index 5a9c36b..240ccaa 100644
--- a/benchmarks/ExpressionBenchmark.cs
+++ b/benchmarks/ExpressionBenchmark.cs
@@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using BenchmarkDotNet.Attributes;
-using BenchmarkDotNet.Configs;
+using BenchmarkDotNet.Columns;
using Pidgin;
@@ -11,89 +11,100 @@ namespace Ramstack.Parsing.Benchmarks;
[MemoryDiagnoser]
[OperationsPerSecond]
-[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
+[HideColumns(Column.Error, Column.StdDev)]
public class ExpressionBenchmark
{
[SuppressMessage("ReSharper", "ArrangeRedundantParentheses")]
- private const double Large = ((3.14159 * 2.3 * 4) / (1.5 - 0.3) + (2.71828 * 2.5)) * ((8.1 * 3.2 + 4.7) / 2.1 - ((15.7 + 2.3 * 4) / (1.5 - 0.3))) - (((3.5 * 1.41421) * (2.71828 + 3.14159)) / 2);
- private const double Small = 2.5 * 4.7 - 8.1 / 3;
+ public const double LargeResult = ((3.14159 * 2.3 * 4) / (1.5 - 0.3) + (2.71828 * 2.5)) * ((8.1 * 3.2 + 4.7) / 2.1 - ((15.7 + 2.3 * 4) / (1.5 - 0.3))) - (((3.5 * 1.41421) * (2.71828 + 3.14159)) / 2);
+ public const double SmallResult = 2.5 * 4.7 - 8.1 / 3;
- private const string SmallExpression = "2.5 * 4.7 - 8.1 / 3";
- private const string LargeExpression = "(( 3.14159 * 2.3 * 4) / (1.5 - 0.3) + (2.71828 * 2.5) ) * ((8.1 * 3.2 + 4.7) / 2.1 - ((15.7 + 2.3 * 4) / (1.5 - 0.3))) - (((3.5 * 1.41421) * (2.71828 + 3.14159)) / 2)";
+ private readonly string _largeExpression = "((3.14159 * 2.3 * 4) / (1.5 - 0.3) + (2.71828 * 2.5)) * ((8.1 * 3.2 + 4.7) / 2.1 - ((15.7 + 2.3 * 4) / (1.5 - 0.3))) - (((3.5 * 1.41421) * (2.71828 + 3.14159)) / 2)";
+ private readonly string _smallExpression = "2.5 * 4.7 - 8.1 / 3";
+
+ public IEnumerable Source
+ {
+ get
+ {
+ yield return new ExpressionInput(_smallExpression, SmallResult, "small");
+ yield return new ExpressionInput(_largeExpression, LargeResult, "large");
+ }
+ }
+
+ [ParamsSource(nameof(Source))]
+ public ExpressionInput Input { get; set; }
[GlobalSetup]
[SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator")]
public void Setup()
{
- if (RamstackParsers.ExpressionParser.Parse(SmallExpression).Value != Small) throw new Exception("Ramstack: Small");
- if (RamstackParsers.ExpressionParser.Parse(LargeExpression).Value != Large) throw new Exception("Ramstack: Large");
-
- if (ParlotParsers.ExpressionParser.Parse(SmallExpression) != Small) throw new Exception("Parlot: Small");
- if (ParlotParsers.ExpressionParser.Parse(LargeExpression) != Large) throw new Exception("Parlot: Large");
-
- if (PidginParsers.ExpressionParser.Parse(SmallExpression).Value != Small) throw new Exception("Pidgin: Small");
- if (PidginParsers.ExpressionParser.Parse(LargeExpression).Value != Large) throw new Exception("Pidgin: Large");
+ (string Name, double Value)[] results =
+ [
+ (nameof(RamstackExpression), RamstackExpression()),
+ (nameof(RamstackExpressionDiagnostics), RamstackExpressionDiagnostics()),
+ (nameof(ParlotExpression), ParlotExpression()),
+ (nameof(ParlotExpressionCompiled), ParlotExpressionCompiled()),
+ (nameof(PidginExpression), PidginExpression())
+ ];
+
+ foreach (var (name, value) in results)
+ if (value != Input.Expected)
+ throw new InvalidOperationException($"{name}: {value} != {Input.Expected} for {Input}");
+
+ (string Name, Func Parse)[] parsers =
+ [
+ ("Ramstack", input => RamstackParsers.ExpressionParser.TryParse(input, out var value) ? value : null),
+ ("Ramstack:Diagnostics", input => RamstackParsers.ExpressionParser.Parse(input) is { Success: true } result ? result.Value : null),
+ ("Parlot", input => ParlotParsers.ExpressionParser.TryParse(input, out var value) ? value : null),
+ ("Parlot:Compiled", input => ParlotParsers.ExpressionParserCompiled.TryParse(input, out var value) ? value : null),
+ ("Pidgin", input => PidginParsers.ExpressionParser.Parse(input) is { Success: true } result ? result.Value : null)
+ ];
+
+ foreach (var (name, parse) in parsers)
+ {
+ if (parse($" \t{_smallExpression}\r\n") != SmallResult)
+ throw new InvalidOperationException($"{name}: surrounding whitespace");
+
+ foreach (var suffix in new[] { " garbage", "+", ")" })
+ if (parse(_smallExpression + suffix) is not null)
+ throw new InvalidOperationException($"{name}: accepted trailing input {suffix}");
+
+ // Every grammar allows a single unary minus before a primary expression.
+ foreach (var (input, expected) in new (string, double?)[] { ("-2", -2), ("- 2 * 3", -6), ("-(1 + 2)", -3), ("--2", 2), ("---2", null) })
+ if (parse(input) != expected)
+ throw new InvalidOperationException($"{name}: unexpected result for unary minus '{input}'");
+ }
}
- [BenchmarkCategory("Small")]
- [Benchmark(Description = "Ramstack:Small")]
- public double RamstackSmall()
+ [Benchmark(Baseline = true, Description = "Ramstack")]
+ public double RamstackExpression()
{
- RamstackParsers.ExpressionParser.TryParse(SmallExpression, out var result);
+ RamstackParsers.ExpressionParser.TryParse(Input.Value, out var result);
return result;
}
- [BenchmarkCategory("Small")]
- [Benchmark(Description = "Ramstack:Diag:Small")]
- public double RamstackSmall_Diagnostics() =>
- RamstackParsers.ExpressionParser.Parse(SmallExpression).Value;
+ [Benchmark(Description = "Ramstack:Diagnostics")]
+ public double RamstackExpressionDiagnostics() =>
+ RamstackParsers.ExpressionParser.Parse(Input.Value).Value;
- [BenchmarkCategory("Large")]
- [Benchmark(Description = "Ramstack:Large")]
- public double RamstackLarge()
- {
- RamstackParsers.ExpressionParser.TryParse(LargeExpression, out var result);
- return result;
- }
+ [Benchmark(Description = "Parlot")]
+ public double ParlotExpression() =>
+ ParlotParsers.ExpressionParser.Parse(Input.Value);
- [BenchmarkCategory("Large")]
- [Benchmark(Description = "Ramstack:Diag:Large")]
- public double RamstackLarge_Diagnostics() =>
- RamstackParsers.ExpressionParser.Parse(LargeExpression).Value;
-
- [BenchmarkCategory("Small")]
- [Benchmark(Description = "Parlot:Small")]
- public double ParlotSmall() =>
- ParlotParsers.ExpressionParser.Parse(SmallExpression);
-
- [BenchmarkCategory("Small")]
- [Benchmark(Description = "Parlot:Compiled:Small")]
- public double ParlotSmallCompiled() =>
- ParlotParsers.ExpressionParserCompiled.Parse(SmallExpression);
-
- [BenchmarkCategory("Large")]
- [Benchmark(Description = "Parlot:Large")]
- public double ParlotLarge() =>
- ParlotParsers.ExpressionParser.Parse(LargeExpression);
-
- [BenchmarkCategory("Large")]
- [Benchmark(Description = "Parlot:Compiled:Large")]
- public double ParlotLargeCompiled() =>
- ParlotParsers.ExpressionParserCompiled.Parse(LargeExpression);
-
- [BenchmarkCategory("Small")]
- [Benchmark(Description = "Pidgin:Small")]
- public double PidginSmall()
+ [Benchmark(Description = "Parlot:Compiled")]
+ public double ParlotExpressionCompiled() =>
+ ParlotParsers.ExpressionParserCompiled.Parse(Input.Value);
+
+ [Benchmark(Description = "Pidgin")]
+ public double PidginExpression()
{
- var result = PidginParsers.ExpressionParser.Parse(SmallExpression);
+ var result = PidginParsers.ExpressionParser.Parse(Input.Value);
return result.Success ? result.Value : 0;
}
- [BenchmarkCategory("Large")]
- [Benchmark(Description = "Pidgin:Large")]
- public double PidginLarge()
+ public readonly struct ExpressionInput(string value, double expected, string name)
{
- var result = PidginParsers.ExpressionParser.Parse(LargeExpression);
- return result.Success ? result.Value : 0;
+ public string Value => value;
+ public double Expected => expected;
+ public override string ToString() => name;
}
}
diff --git a/benchmarks/JsonBenchmark.cs b/benchmarks/JsonBenchmark.cs
index 31910be..cb464b3 100644
--- a/benchmarks/JsonBenchmark.cs
+++ b/benchmarks/JsonBenchmark.cs
@@ -1,4 +1,7 @@
-using BenchmarkDotNet.Attributes;
+using System.Text.Json;
+
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Columns;
using Ramstack.Parsing.Benchmarks.Parsers;
@@ -6,26 +9,103 @@ namespace Ramstack.Parsing.Benchmarks;
[MemoryDiagnoser]
[OperationsPerSecond]
+[HideColumns(Column.Error, Column.StdDev)]
public class JsonBenchmark
{
- public static readonly string JsonBig = File.ReadAllText("Data/twitter.json");
- public static readonly string JsonMedium = File.ReadAllText("Data/medium.json");
- public static readonly string JsonSmall = File.ReadAllText("Data/small.json");
+ private readonly string _jsonBig = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data/twitter.json"));
+ private readonly string _jsonMedium = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data/medium.json"));
+ private readonly string _jsonSmall = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data/small.json"));
public IEnumerable Source
{
get
{
- yield return new JsonString(JsonSmall.TrimEnd(), "small");
- yield return new JsonString(JsonMedium.TrimEnd(), "medium");
- yield return new JsonString(JsonBig.TrimEnd(), "big");
+ yield return new JsonString(_jsonSmall, "small");
+ yield return new JsonString(_jsonMedium, "medium");
+ yield return new JsonString(_jsonBig, "big");
}
}
[ParamsSource(nameof(Source))]
public JsonString Input { get; set; }
- [Benchmark(Description = "Ramstack")]
+ [GlobalSetup]
+ public void Setup()
+ {
+ var expected = JsonSerializer.Deserialize(Input.Value);
+ (string Name, object? Value)[] results =
+ [
+ (nameof(RamstackJson), RamstackJson()),
+ (nameof(ParlotJson), ParlotJson()),
+ (nameof(ParlotJsonCompiled), ParlotJsonCompiled()),
+ (nameof(PidginJson), PidginJson()),
+ (nameof(NewtonsoftJson), NewtonsoftJson()),
+ (nameof(SystemTextJson), SystemTextJson())
+ ];
+
+ foreach (var (name, value) in results)
+ {
+ var actual = value is Newtonsoft.Json.Linq.JToken token
+ ? JsonSerializer.Deserialize(token.ToString(Newtonsoft.Json.Formatting.None))
+ : JsonSerializer.SerializeToElement(value);
+
+ if (!Equivalent(actual, expected))
+ throw new InvalidOperationException($"{name}: incorrect JSON model for {Input}");
+ }
+
+ (string Name, Func Parse)[] parsers =
+ [
+ ("Ramstack", input => RamstackParsers.JsonParser.TryParse(input, out _)),
+ ("Parlot", input => ParlotParsers.JsonParser.TryParse(input, out _)),
+ ("Parlot:Compiled", input => ParlotParsers.JsonParserCompiled.TryParse(input, out _)),
+ ("Pidgin", input => PidginParsers.JsonParser.TryParse(input, out _))
+ ];
+
+ foreach (var (name, parse) in parsers)
+ {
+ if (!parse($" \t{Input.Value}\r\n"))
+ throw new InvalidOperationException($"{name}: surrounding whitespace");
+
+ if (parse($"{Input.Value} garbage"))
+ throw new InvalidOperationException($"{name}: accepted trailing input");
+ }
+ }
+
+ // Combinator parsers store numbers as double; compare numeric values, not JSON formatting.
+ private static bool Equivalent(JsonElement actual, JsonElement expected)
+ {
+ if (actual.ValueKind != expected.ValueKind)
+ return false;
+
+ switch (expected.ValueKind)
+ {
+ case JsonValueKind.Object:
+ if (actual.EnumerateObject().Count() != expected.EnumerateObject().Count())
+ return false;
+
+ foreach (var property in expected.EnumerateObject())
+ if (!actual.TryGetProperty(property.Name, out var value) || !Equivalent(value, property.Value))
+ return false;
+
+ return true;
+
+ case JsonValueKind.Array:
+ return actual.GetArrayLength() == expected.GetArrayLength()
+ && actual.EnumerateArray().Zip(expected.EnumerateArray()).All(pair => Equivalent(pair.First, pair.Second));
+
+ case JsonValueKind.Number:
+ // ReSharper disable once CompareOfFloatsByEqualityOperator
+ return actual.GetDouble() == expected.GetDouble();
+
+ case JsonValueKind.String:
+ return actual.GetString() == expected.GetString();
+
+ default:
+ return true;
+ }
+ }
+
+ [Benchmark(Baseline = true, Description = "Ramstack")]
public object? RamstackJson()
{
RamstackParsers.JsonParser.TryParse(Input.Value, out var result);
@@ -50,7 +130,7 @@ public IEnumerable Source
[Benchmark(Description = "System.Text.Json")]
public object? SystemTextJson() =>
- System.Text.Json.JsonSerializer.Deserialize