DotGram 0.2.0
dotnet add package DotGram --version 0.2.0
NuGet\Install-Package DotGram -Version 0.2.0
<PackageReference Include="DotGram" Version="0.2.0"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
<PackageVersion Include="DotGram" Version="0.2.0" />
<PackageReference Include="DotGram"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
paket add DotGram --version 0.2.0
#r "nuget: DotGram, 0.2.0"
#:package DotGram@0.2.0
#addin nuget:?package=DotGram&version=0.2.0
#tool nuget:?package=DotGram&version=0.2.0
.Gram
.Gram is a source generator that compiles grammars into strongly typed C# parsers, from single-character rules to the SQL standard.
The grammar is known at compile time. The generated parser is ordinary C# in your own assembly — there is no parser engine, grammar graph, or runtime library to interpret, and nothing to deploy beside your application.
Getting started
<PackageReference Include="DotGram" Version="0.2.0"
PrivateAssets="all" ExcludeAssets="runtime" />
A grammar lives in the [Gram] attribute of the class the parser is generated into, or in
a .gram file listed as <AdditionalFiles Include="Name.gram" />. An inline grammar is a
raw string literal, so it needs C# 11; a .gram file needs nothing more than the project
already has.
Generation summary in Build Output
Enable a per-parser build message in the consuming project:
<PropertyGroup>
<DotGramReportGeneration>summary</DotGramReportGeneration>
</PropertyGroup>
Or run dotnet build -p:DotGramReportGeneration=summary. The NuGet package imports
its MSBuild target automatically; direct analyzer references need to import
build/DotGram.targets from the source project.
The property takes three values. none is the default and says nothing at all.
summary prints one line per parser, at an importance a minimal build shows: the host
(and named variant), the normalized rule count, the generated C# size in UTF-8 bytes,
and the generation time. full prints, beside each of those, the actual
lexical/character mode, the requested strategy options and the carrier of every machine
in the grammar — at an importance -v:normal and above show, so a quiet build stays
quiet whichever is set. true and false are the older spellings of full and none.
Rule counts include normalized specializations and library rules. Individual publications may fall back from the requested options; their existing diagnostics remain authoritative.
Time measures grammar compilation and emission, excluding C# compilation, host discovery and symbol queries. A cached generator result retains its original timing. A build that skips compilation prints no summary. Design-time builds do not report.
Anything above none enables compiler-generated files on disk and adds a comment-only
report file; full also costs the analysis that decides the carriers, which is worked
out for the report and for nothing else. Keep the default for reproducible artifacts.
No value changes parser behavior or turns messages into warnings.
One grammar, three parsers
A grammar does not have to describe only one parser. The arithmetic below is written once
and published three times: over int, over decimal, and as a tree.
using System;
using DotGram;
[Gram("""
@using System.Globalization;
trivia = Std.Spacing?
Value : @int = d: Std.Digits => @(int.Parse(d))
Point = Std.Digits & ('.' & Std.Digits)?
Expr : Value = left: Expr & '+' & right: Expr << 1 => @(left + right)
| left: Expr & '-' & right: Expr << 1 => @(left - right)
| left: Expr & '*' & right: Expr << 2 => @(left * right)
| left: Expr & '/' & right: Expr << 2 => @(left / right)
| left: Expr & '^' & right: Expr >> 3 => @(Raise(left, right))
| '-' & operand: Expr >> 3 => @(-operand)
| '(' & inner: Expr & ')' => @(inner)
| value: Value => @(value)
IntNumber : @int = d: Std.Digits => @(int.Parse(d))
DecimalNumber : @decimal = d: Point => @(decimal.Parse(d, CultureInfo.InvariantCulture))
NodeNumber : @Node = d: Point => @(new Node.Number(decimal.Parse(d, CultureInfo.InvariantCulture)))
parse Expr with (Value = IntNumber) as EvaluateInt
parse Expr with (Value = DecimalNumber) as EvaluateDecimal
parse Expr with (Value = NodeNumber) as BuildTree
""")]
public static partial class Calculator
{
/// <summary>The tree the third parser builds, and the operators that build it.</summary>
public abstract record Node
{
public sealed record Number(decimal Of) : Node;
public sealed record Binary(char Op, Node Left, Node Right) : Node;
public sealed record Negate(Node Of) : Node;
public static Node operator +(Node left, Node right) => new Binary('+', left, right);
public static Node operator -(Node left, Node right) => new Binary('-', left, right);
public static Node operator *(Node left, Node right) => new Binary('*', left, right);
public static Node operator /(Node left, Node right) => new Binary('/', left, right);
public static Node operator -(Node of) => new Negate(of);
}
static int Raise(int left, int right) => (int)Math.Pow(left, right);
static decimal Raise(decimal left, decimal right) => (decimal)Math.Pow((double)left, (double)right);
static Node Raise(Node left, Node right) => new Node.Binary('^', left, right);
}
Three parsers come out of it:
int half = Calculator.EvaluateInt("7 / 2"); // 3
decimal exact = Calculator.EvaluateDecimal("7 / 2"); // 3.5
int tower = Calculator.EvaluateInt("2 ^ 3 ^ 2"); // 512, since `^` groups to the right
int negated = Calculator.EvaluateInt("-2 ^ 2"); // -4, since a prefix is where an expression starts
var refused = Calculator.TryEvaluateInt("1.5"); // no match: `Value` is `IntNumber` there
var tree = Calculator.BuildTree("1 - 2 - 3");
// case Binary(-, Binary(-, Number(1), Number(2)), Number(3)) :
The whole language is one rule. << n reads the operand on its right one strength
tighter, so the operator groups to the left; >> n reads it at n, so it groups to the
right. What separates the three parsers is the publication:
parse Expr with (Value = IntNumber) as EvaluateInt
parse Expr with (Value = DecimalNumber) as EvaluateDecimal
parse Expr with (Value = NodeNumber) as BuildTree
with substitutes a rule through the grammar reachable from that publication, and the
result type follows it: Expr : Value means "the type produced by Value". The actions
do not change either — left + right is C#, and over Node it is the operator declared
beside the grammar, which builds a node instead of adding anything.
Named captures become the result
using DotGram;
[Gram("""
Feed = header: Header & rows: Row* & trailer: Trailer & eof
Header = "H" & '|' & date: Date & eol
Row = "R" & '|' & symbol: Text & '|' & quantity: Digit+ & eol
Trailer = "T" & '|' & count: Digit+ & eol
Date = year: Digit{4} & '-' & month: Digit{2} & '-' & day: Digit{2}
Text = [^ '|' | '/r' | '/n']+
Digit = ['0'..'9']
parse Feed
find Row as AllRows
""")]
public static partial class FeedParser;
The parser returns that structure directly:
var text = """
H|2026-09-20
R|ABC|100
T|1
""";
var feed = FeedParser.ParseFeed(text);
var year = feed.Header.Date.Year; // 2026
var symbol = feed.Rows[0].Symbol; // ABC
var count = feed.Trailer.Count; // 1
foreach (var row in FeedParser.AllRows(text))
Console.WriteLine(row.Value.Symbol);
There is no generic parse tree and no visitor required to turn it into application data.
Captures can also be matched straight to a constructor or to the required properties of
a type you already have, in which case the grammar contains no construction code at all.
C# where a grammar cannot go
@ is the boundary. A rule declares its result as a C# type and builds it with an action;
a guard asks a C# question in the middle of a parse:
using DotGram;
[Gram("""
Name = ['a'..'z' | 'A'..'Z']+
Tag
= '<' & open: Name & '>'
& "</" & close: Name & '>'
& when @(open == close)
parse Tag
""")]
public static partial class Tags;
The same boundary calls predicates, external recognizers and constructors.
Versions of one language
A language changes between releases. SQL Server took the *= outer join out after 2000 and
put IS DISTINCT FROM in with 2022. A grammar says that once, and each version is a parser
generated from it:
using DotGram;
[Gram("""
trivia = ' '*
wordboundary = ['a'..'z']
Version = "2000" | "2008" | "2022"
Name = { ['a'..'z']+ }
Test : @string
= l: Name & "*=" & r: Name & when Version is "2000"
=> @($"{l} left-joined to {r}")
| l: Name & "is" & "distinct" & "from" & r: Name & when Version is "2022"
=> @($"{l} differs from {r}")
| l: Name & "=" & r: Name
=> @($"{l} equals {r}")
parse Test with (Version = "2000") as ParseOld
parse Test with (Version = "2022") as ParseNew
parse Test as ParseAny
""")]
public static partial class SqlDialect;
var joined = SqlDialect.ParseOld("a *= b"); // a left-joined to b
var refused = SqlDialect.TryParseNew("a *= b"); // IsSuccess is false
var differs = SqlDialect.ParseAny("a is distinct from b"); // a differs from b
when Version is "2000" is decided when the parser is generated, not while it runs:
with (Version = "2000") substitutes the rule for that one publication, and an alternative
whose condition is false is not in that parser at all. ParseAny names no version, so it
keeps every one and reads them all.
What .Gram supports
- literals, element sets, ranges and Unicode categories;
- sequence, ordered choice,
?,*,+and bounded repetition; - lookahead and atomic groups;
- named captures and generated result types;
- existing C# result types, filled by constructor or by
requiredproperties; - semantic actions, guards, external C# predicates and recognizers;
- parameterized rules, rule rebinding and parser specialization;
- conditions about the grammar —
when Version is "2022"— decided when a parser is generated, which is how one grammar is several versions of a language; - left recursion — direct, and indirect through rules that only forward — with binding powers for expression grammars;
- grammar namespaces, and grammar libraries that cross a project reference;
- a lexical split: the same notation read over tokens instead of characters;
Parse,TryParse— a match, orboolwith the value in anout— andFind; describing a refusal can cost reading the refused input a second time, which theoutform never does;- streaming from a
TextReader, and recovery inside repetitions.
No runtime parser library
Everything a generated parser needs is emitted into the consuming assembly as internal C#. There is no runtime assembly to deploy, and no generator/runtime version pair that can drift apart.
Threads
A generated parser's static methods may be called from any number of threads at once, and a parse reached from inside another's action or guard is safe the same way. What one call needs while it runs is kept per thread and handed back when the call returns; a nested call takes a spare of its own rather than contending with the call that reached it. What is shared between threads is only what the grammar never changes once generated. A thread whose one large parse needed an oversized buffer is not charged for it forever: the buffer is let go once a few calls in a row stop needing it that size.
None of this reaches your own C#. A =>, a when, a recovery hook or an external
recognizer that keeps state of its own — a static field, a cache — is your code, and
making it safe under concurrency is your job there the same as anywhere else you write
it: see §7.7
on why a when should not simply write one.
A parse from memory deep enough to run low on stack does not fail: it is carried onto a
fresh thread with room of its own and continues there, deeper still if that is not
enough, while the thread that made the call waits (the attribute's Stacks bounds how
many). This is a hand-off, not concurrency, but an action, guard or external recognizer
reached past that point is not running on the thread the call came in on: code that
depends on which thread it runs on cannot assume it stays the one that started the parse.
A reader over a TextReader or a Stream does not do this, and keeps none of the pool
above either: each call leases its buffer from .NET's shared array pool and gives it back
when the call ends.
Compatibility
The generated parser is C# 8 and targets whatever the project around it targets.
netstandard2.0, net472 and net8.0 all compile it.
- A grammar written inside
[Gram]is a raw string literal, which is C# 11. Onnetstandard2.0andnet472the default is C# 7.3, so<LangVersion>has to be set. A grammar in a.gramfile asks for nothing. netstandard2.0andnet472needSystem.Memory, for theReadOnlySpan<char>the generated methods take.
The generator is a Roslyn analyzer built against Microsoft.CodeAnalysis 4.14 and needs a
compiler at least that new.
Documentation
Everything else is at github.com/dotgram/dotgram: the notation in full, the diagnostics, whole parsers to copy, and the benchmarks.
DotGram.Web is a
package of its own — RFC 3986 URIs, RFC 5646 language tags, RFC 6570 URI templates,
RFC 9651 structured field values and the rest of the formats of the web —
DotGram.Sql another —
Microsoft's T-SQL, which includes SQL-92's grammar for what the two share and builds one
tree of records with it, and ISO SQL:2023, a separate grammar with a tree of its own —
DotGram.ExpressionLanguage
a third — a C#-style expression language that builds System.Linq.Expressions trees — and
DotGram.Finance a
fourth: FIX 4.2, 4.4 and 5.0 SP2 tag-value messages. Two of them
hold the largest grammars there are to read —
T-SQL in over 1,000
rules, and the
expression language in
over 80. Each of the four ships its own README.md and SKILL.md, which say what its
parsers publish and what is easy to get wrong.
MIT. The licence ends with one addition, for what the generator writes, quoted here as it stands there:
Generated output. The C# source that the DotGram generator writes into a compilation of yours — the parser and the support code it copies there — may be used, modified and distributed under terms of your choice, without reproducing the copyright and permission notices above. The disclaimer of warranty above still applies to it.
Learn more about Target Frameworks and .NET Standard.
-
.NETStandard 2.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
Since 0.1.0. A publication that asks only whether the input is what it says: bool TryParseR(input, out R value), one quiet reading with no message. The forms that begin where they are told: a position, a window, and both with the position moved past what was read; a reading stops where the rule ends, and reading a long text one value at a time costs the text once rather than once per value. A carrier that builds where it reads, chosen per grammar, with recovery and streaming under it. A reader over buffered input, over characters and over bytes. A yield publication stepped by the reader, one turn a MoveNext. New diagnostics, among them GRAM0009 and the report a build writes on request about carriers and the reasons for them. Two breaks to know about if you are coming from 0.1.0's positional forms: a reading that begins where it is told no longer reads the trivia after the rule, so Match.Position and Match.Length are the value's own extent and a caller that measured a value by Length reads a shorter one — a caller that only goes on from the position it was handed is unaffected; and over a grammar cut into tokens such a reading is no longer refused by a character no token begins with standing somewhere it never read, so a script with one bad character in its last line now reads the statements before it. In an editor, an edit to the class a grammar is attached to no longer compiles the grammar again unless the grammar changed, whether the grammar is a .gram file or written in the [Gram] attribute.One further break: a capture a guard can only reach through its own assignment is now handed to the `when` as the value rather than as a nullable — `T` where it was `T?` — so a condition written against the old signature with `.HasValue`, `.Value` or `.GetValueOrDefault()` no longer compiles and reads the value directly instead, and a comparison against null is reported as CS0472, the result being always true. A capture that is genuinely optional on the path is unchanged and still arrives nullable. Fixed: a `when` over a repeated text capture whose body holds anything else — `(y: D & ','?){2}` over "1,2" — was handed only the last turn rather than the turns joined, so a guard could refuse what the rule would have built, or accept what it should have refused; a `switch` selector over the same kind of capture had the same defect and is fixed with it. Fixed, and present since 0.1.0: a reading that gave back an alternative could leave a record pointing at one it had rolled back. On a thread whose first parse met it, the parse threw IndexOutOfRangeException; on a thread an earlier, larger parse had warmed, it went on without saying so. Every input found to reach it was one the grammar refuses, and the refusal it returned was the right one; no accepted input was found to reach it, over the tests of every grammar here and some 7,700 statements of real T-SQL. A parser has to be generated again to get the fix. Fixed: a capture over a lookahead inside a repetition, such as `(y: ?=D & D & ','?){2}`, now joins its pieces instead of keeping only the last one, so a guard and the built value agree. Where a lookahead's pieces overlap, the join now asks each pair of neighbours whether they meet, instead of summing lengths that could cancel the overlap out. Fixed, in a left-recursive (fold) rule: a step's guard and construction now get the step's own captures, not an earlier step's or the base's. A value repeated inside a step is now the step's array, not its last piece. An absent optional inside a step now reaches the guard and the construction as null, not as the base's piece or an empty string. Two compile errors in the emitted code, CS1503 and CS0103, are gone. New refusal: GRAM4007 now refuses a fold step that collects a value another alternative holds once — a behaviour change, since such a grammar used to compile and silently hand the step only the last value it collected. In an editor, a grammar written inline in a `[Gram("…")]` attribute is no longer compiled again for an edit anywhere else in its host file — moving or editing unrelated code above or below the attribute used to recompile it too; only an edit to the grammar's own text does now, as a `.gram` file grammar already worked. An external `@Name` is now found by asking the compilation for that name directly rather than by scanning and testing every name it declares, which made re-checking a grammar after a host-only edit faster. Fixed: a guard's shortcut for finding the record it stands in could read a record a give-back had already discarded and build the wrong kind of value into it, which could throw IndexOutOfRangeException or NullReferenceException in a generated parser on some refused input, or rarely use the wrong value where nothing threw; no accepted input was found to reach it. In a left-recursive (fold) rule on the immediate carrier, a step given back after it had itself read a value of the rule's own type could leave that abandoned value standing in place of the fold's value so far, changing an accepted result. A step's construction — the code built for `=>` — now sees the text of its own application, from where the fold began to where that step ended, rather than the whole call's text, wherever it asks for `parserText`, `parserSpan` or its location. New diagnostic GRAM4031: a guard in a fold step that names another step's captured name for the value so far, which this step has none of, is refused at generation time instead of being left as a name unbound anywhere in the emitted guard method. The C# the generator writes into your compilation — the parser and the support code it copies there — may now be used, modified and distributed under terms of your choice, without carrying the MIT notice; see the end of LICENSE and the package README. Fixed: a literal that ignores case ('s'i) after a lookahead for the same letters could make the generated reader refuse text the grammar accepts ("aS" after (?!'s' & any)+); and a run that a padded separator such as ' '* & '|' & ' '* ends no longer costs the square of the padding in the generated reader, as it already did not in the engine. Recovering past a field nothing recognizes had the same shape and cost: it searched again from every character of the run's padding for where it might end, and now scans ahead the same way reading does. A run followed by more of the same optional padding before its own stop, as in `Text & ' '* & ('|' & ' '* | eof)`, is now proved, wherever nothing in the grammar could ask for a shorter reading there, never to need to give characters back either, so it too reads in one pass instead of a turn at a time. A run bounded by a small set of stop characters — this scan among them — is found by searching for them directly, and that search took only a stop literal's written casing where the literal ignores case, so it could run past what the literal folds to as well ('s'i also reads U+017F, 'μ'i reads U+00B5), reading text into a value the stop should have ended, or refusing text the grammar accepts; the search now falls back to the ordinary matcher for such a literal. Fixed: a guard's `when` condition was handed every capture whose name merely appeared inside its C# text, not only the ones the condition actually names — a guard that only wrote `context` was also handed a capture named `c`, one that only wrote `parserSpan` was also handed `p` — which could throw building a value nothing asked for. A capture named for a C# keyword had the opposite failure: the same search never matches the escaped parameter it becomes (`@class` for `class`), so naming it in a guard lost it to a compile error or a silent read of an unrelated same-named member in scope. Both are fixed by asking only for the names a condition's own C# actually contains.