DotGram.ExpressionLanguage
0.2.0
dotnet add package DotGram.ExpressionLanguage --version 0.2.0
NuGet\Install-Package DotGram.ExpressionLanguage -Version 0.2.0
<PackageReference Include="DotGram.ExpressionLanguage" Version="0.2.0" />
<PackageVersion Include="DotGram.ExpressionLanguage" Version="0.2.0" />
<PackageReference Include="DotGram.ExpressionLanguage" />
paket add DotGram.ExpressionLanguage --version 0.2.0
#r "nuget: DotGram.ExpressionLanguage, 0.2.0"
#:package DotGram.ExpressionLanguage@0.2.0
#addin nuget:?package=DotGram.ExpressionLanguage&version=0.2.0
#tool nuget:?package=DotGram.ExpressionLanguage&version=0.2.0
DotGram.ExpressionLanguage
A C#-style expression language written in .gram, compiled into System.Linq.Expressions
trees.
It is an ordinary C# library. .Gram generated the parser into this assembly when it was compiled, so nothing here carries a parser runtime, and neither does anything that references it.
using System;
using DotGram.ExpressionLanguage;
var square = ExpressionParser.Compile<Func<int, int>>("(int x) => x * x - 1");
square(3); // 8
It is not a sandbox: do not compile text you do not trust. See It is not a sandbox below.
It reads C#'s operators, at C#'s precedence, and its literals down to the digit separator
and the verbatim string. Past expressions it has typed locals, blocks, if, while, do,
for, switch — the statement and x switch { 1 or 2 => …, _ => … } —
try/catch/finally, throw, break, continue and return, and
past the keywords it has members, calls, indexers, new with initializers, tuples, generic
types, is, as, casts and checked:
var calculate = ExpressionParser.Compile<Func<int, int, int>>(
"""
(int x, int y) =>
{
int sum = x + y;
return sum * sum;
}
""");
calculate(2, 3); // 25
Or keep the expression tree instead of compiling it:
var expression = ExpressionParser.Parse("(double x) => x / 2.0");
Console.WriteLine(expression); // x => (x / 2)
Or ask, rather than catch: TryParse answers for everything Parse would throw for — text
that is not this language, and text that is and means nothing, such as a name nothing
declares or an operator its operands do not support.
var match = ExpressionParser.TryParse("(string s) => s - 1");
if (!match.IsSuccess) // there is no minus over a string
Console.WriteLine(match.Error); // what Expression.Subtract said about String and Int32
Compile, Parse and TryParse may all be called from any number of threads at once — the
generated parser's own contract — and the member-resolution caches behind name and overload
lookups are shared across every call and thread, kept in their own concurrent tables rather
than per call.
A type named rather than spelled as a keyword is found the way C# finds one: written whole,
or through a using at the top of the text. Five namespaces are there already — the set a
new project gets: System, System.Collections.Generic, System.Linq, System.Text and
System.Threading.Tasks — and a text's own usings are its own, so the next text starts
with those five again and nothing more. ResolutionScope.DefaultImports names them, and
ResolutionScope.WithoutDefaultImports() is a scope that leaves them out. A using may also
give a name to one type or one namespace (using L = System.Collections.Generic.List<int>;),
or bring a type's static members, nested types and extension methods into reach as bare names
(using static System.Math; () => Abs(-2)). A name the text itself declares wins over one a
using static gives, as in C#; and static is a keyword here, as it is in C#, so nothing may
be named it. What a text can name is what C# written in the calling assembly could: public
types, and that assembly's own internal types and members. A full name that two referenced
assemblies declare as two different types is refused where it is used, naming both assemblies,
as C# refuses it (CS0433). One type that several assemblies answer for, as a facade forwards
System.Object, is one type; and the calling assembly's own type wins over a reference's, as
the compilation's does in C#.
using System.Collections.Generic;
// `System.Collections.Generic` is one of the five, so the text needs no `using` of its own.
var count = ExpressionParser.Compile<Func<IList<int>, int>>("(IList<int> l) => l.Count");
// One it does not get, it says itself.
var named = ExpressionParser.Compile<Func<string>>(
"""
using System.Globalization;
() => CultureInfo.InvariantCulture.Name
""");
Conversions, operators and overloads follow C#'s rules: x + 1.5 over an int is a
double, byte b = 1 fits, and Math.Sqrt(x) finds the double overload. A generic
method takes its type arguments from its arguments, an extension method is found through a
using, and a lambda that says no types takes them from the overload it is handed to, so
a.Where(n => n > 1).Sum() reads as it does in C#. The lambda a text IS takes them the same
way from the delegate it is compiled to, so Compile<Func<int, int>>("x => x * x") reads
with no types written at all. Where it is not C# — a method is never
called with its type arguments written, and a constant is folded only across a minus — is
written down, with the reason for each, at the top of the file below.
Where a name is looked for is the caller's to say. By default it is the calling assembly and
what it references — what a compilation of that assembly would see — so the same text answers the
same whatever else the process has loaded. ResolutionScope.Of(caller, plugin) widens that,
WithoutInternals() reads as another assembly would, and what a name MEANS stays C#'s either way.
A caller loaded into an AssemblyLoadContext of its own has its references found in that
context, as the runtime finds them for its own code.
The grammar calls System.Linq.Expressions factories directly. There is no intermediate
AST specific to .Gram that must later be translated into an expression tree — which also
means a factory that does not exist, or one handed the wrong type, is a C# error on the
line of the grammar that asked for it rather than an exception at run time.
The grammar and the C# it calls are one file,
ExpressionParser.cs.
It is not a sandbox
Parse only text you would run as code. A text can name any type the reading can reach and call any member on it — the file system, the process, reflection — and what it compiles to runs with the host's permissions, in the host's process, for as long as the delegate is called. There is no allow-list, no timeout and no quota, and none is planned: this compiles expressions, it does not contain them.
That is the same position System.Linq.Expressions itself takes, and the same one
CSharpScript takes. It matters here because the input LOOKS like data — a formula in a
configuration file, a rule typed into a form — and a formula from somewhere you do not control
is code from somewhere you do not control.
If the text comes from a user, the answer is not to inspect it before parsing. It is to run it where it can do no harm: a process of its own, with the rights you are willing to lose.
Taking it
dotnet add package DotGram.ExpressionLanguage
There is no companion runtime package, and no generator to install alongside it: the parser was generated when this assembly was compiled.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- System.Memory (>= 4.6.3)
-
net10.0
- No dependencies.
NuGet packages (1)
Showing the top 1 NuGet packages that depend on DotGram.ExpressionLanguage:
| Package | Downloads |
|---|---|
|
DotGram.Finance
FIX 4.2, 4.4 and 5.0 SP2 tag-value parsing and validation: the wire read by a DotGram grammar, and each version's typed messages and checks generated from the FIX repository. |
GitHub repositories
This package is not used by any popular GitHub repositories.
Since 0.1.0. Lambdas whose parameters say no types, read where the types are known; interpolated strings, verbatim and raw; a resolution scope a host hands in, so where a name is looked for is the host's to decide — and what it means stays C#'s. Names are found in the calling assembly and what it references, as a compilation's are, and no longer in whatever the process happens to have loaded; a caller loaded into a load context of its own, a plugin, has its references found in that context, as the runtime finds them for its code. Five namespaces are there already, the set a new project gets (System, System.Collections.Generic, System.Linq, System.Text, System.Threading.Tasks); ResolutionScope.DefaultImports is the set and WithoutDefaultImports() a scope without them. `using static` brings a type's static methods, members and nested types into reach as bare names and its extension methods into the extension search, with a name the text declared winning over one it gives, as in C#; `static` is a keyword now, as it is in C#, so a text may no longer name anything it — a parameter called `static` was accepted before and is refused now. A `using` may also give a name to one type or one namespace (`using L = System.Collections.Generic.List<int>;`), which beats a name a `using` brings in and may not take one the global namespace has, as in C#. A full name that two referenced assemblies declare as two different types is refused where it is used, naming both assemblies, as C# refuses it (CS0433); it used to mean whichever assembly was found first. The calling assembly's own type still wins over a reference's, as in C#. Parse, TryParse and Compile are what is public, and TryParse now also answers whether without a message. Faster: an expression of a thousand terms no longer costs the square of its length, nor does a text of many blocks each declaring a name; a name that binds a parameter or a local is never looked for as a type, as in C#. The assemblies a scope covers are loaded on the first text that names a type, about 20 ms once per calling assembly and process; a text that names none loads nothing. Faster: an element of a name such as `a[0]` — nested as `a[a[a[0]]]`, a text C# accepts too — no longer reads its operand twice, once to look for a following `=` and again as a postfix operand; nesting cost exponential time before (nine seconds at twenty-four levels) and is linear now. The same double reading was in a nested array initializer's braces (`new object[] { new object[] { … x`, unclosed): allocation grew exponentially with nesting depth before and is flat now. The one outward change on accepted input, or on most refused input, is where an array initializer's own braces go wrong: `new int[] { {1} }` was refused as "'Int32[]' has no constructor taking ()" and is now refused as "Expected '}'", at the point the braces themselves fail to close. Speed against 0.1.0: the language reads more of C# (switch expressions, tuples, using static and aliases, element assignment in linear time), and an ordinary expression parses 10-30% slower than in 0.1.0 while allocating less, deeply nested parentheses about 55% slower, and an untyped lambda about 60% faster. A refused text read through the Match form is read twice to say what was expected; the bool form reads it once.