Celly — Native CEL (Common Expression Language) for .NET¶
Status (2026-07-17): COMPLETE. All milestones shipped; conformance is 2456/2456 (100%) with an empty known-failures ratchet. Beyond this plan, a strong-enum mode (
ProtoTypeRegistry.FromFiles(strongEnums: true, …)) was added so the suite's strong_* enum sections pass under the semantics they document. This file is preserved as the original design document.
Context¶
Build a from-scratch, pure managed C# implementation of Google's Common Expression Language (CEL, github.com/cel-expr/cel-spec — formerly google/cel-spec) as a NuGet package family with complete spec support, validated against the official conformance suite. No WASM shims, no Go-compiled artifacts, no native bindings — native .NET processing throughout.
The field is open: existing .NET options (Cel by TELUS, Cel.NET port of cel-java) are partial; no conformance-passing native implementation exists. Official implementations are cel-go/cel-cpp/cel-java/cel-rust only.
Confirmed decisions:
- Hand-written lexer + recursive-descent parser (no ANTLR)
- Split packages: Celly core (zero dependencies) + Celly.Protobuf (Google.Protobuf integration)
- net8.0+ only
- xUnit unit tests + official cel-spec conformance suite (29 textproto files, cel.expr.conformance.test.SimpleTestFile)
- matches() via System.Text.RegularExpressions with RegexOptions.NonBacktracking (linear-time RE2-like guarantees, pure managed), behind a pluggable IRegexEngine
- Public API modeled on cel-go: CelEnv → Compile → CelProgram → Eval(activation)
Solution layout¶
celly/
├── Celly.sln
├── Directory.Build.props # net8.0, nullable, TreatWarningsAsErrors, package metadata
├── src/
│ ├── Celly/ # core package — ZERO dependencies
│ └── Celly.Protobuf/ # Celly + Google.Protobuf
├── tests/
│ ├── Celly.Tests/ # xUnit unit tests
│ ├── Celly.Protobuf.Tests/
│ ├── Celly.Conformance/ # official suite runner
│ └── Celly.Benchmarks/ # BenchmarkDotNet
├── proto/ # vendored cel-spec protos (pinned tag)
├── testdata/ # vendored conformance data as .binpb + known-failures.txt
└── tools/vendor-conformance.sh # clone cel-spec → protoc --encode textproto→binpb
Core architecture (namespaces in Celly)¶
| Namespace | Contents |
|---|---|
Celly |
CelEnv (immutable/thread-safe; Extend/Parse/Check/Compile/Program), CelProgram, CompileResult+Issues, IActivation/Activation |
Celly.Common |
Source, Location, CelIssue, Operators table, Overloads (spec overload-id constants — must match cel-spec exactly) |
Celly.Ast |
Native AST mirroring cel.expr syntax shape: ConstExpr, IdentExpr, SelectExpr(TestOnly), CallExpr, ListExpr, StructExpr, MapExpr, ComprehensionExpr(+IterVar2); SourceInfo with macro-call map |
Celly.Parsing |
Lexer, Parser (recursive descent; iterative for binary-op chains so spec capacity minimums — 32 chained ops, 24 ternaries, 12 nesting — don't blow the stack; MaxRecursionDepth default 250), IMacro/MacroExpander |
Celly.Types |
Single unified CelType model shared by checker AND runtime (ListType(T), MapType(K,V), TypeParamType, StructTypeRef, DynType, OptionalType(T), singletons for primitives) |
Celly.Checking |
Checker, type unification (TypeSubstitution), StandardDecls (~250 overloads), container C.name resolution, outputs type map + reference map |
Celly.Values |
CelValue hierarchy + trait interfaces |
Celly.Interpreter |
Planner (AST → IInterpretable tree, overload pre-binding from reference map, constant folding), comprehension eval, attribute resolution, partial activations/unknowns |
Celly.Stdlib |
Standard functions incl. Rfc3339 (hand-written, 0–9 fractional digits) and GoDoubleFormatter |
Celly.Extensions |
Opt-in ICelLibrary per extension: Strings (incl. format, quote), Math, Encoders, Lists, Sets, Bindings (cel.bind), TwoVarComprehensions, Regex, Optionals |
Celly.Providers |
Core↔protobuf seam: ITypeProvider (FindStructType/FindStructFieldType/FindIdent/NewValue), ITypeAdapter, ITypeRegistry; core ships NativeTypeRegistry (primitives, IDictionary, IEnumerable) |
Celly.Protobuf: ProtoTypeRegistry : ITypeRegistry (from descriptors), lazy ProtoMessageValue/ProtoListValue/ProtoMapValue, WellKnownTypeAdapter (wrappers→primitives, Struct/Value/ListValue, Any unpack, Timestamp/Duration), AstConverter (native AST ↔ cel.expr.ParsedExpr/CheckedExpr), ValueConverter (cel.expr.Value ↔ CelValue), proto2 extensions runtime (proto.getExt/hasExt).
Key design decisions (.NET-specific)¶
- Value model:
abstract class CelValuewith sealed subclasses (IntValue(long),UintValue(ulong),DoubleValue,BoolValue,StringValue,BytesValue(ReadOnlyMemory<byte>),NullValue,TypeValue,TimestampValue,DurationValue,ListValue/MapValuebases,ErrorValue,UnknownValue,OptionalValue). Trait interfaces drive dispatch (IComparableValue,ISizedValue,IIndexer,IAdder, …). Errors/unknowns are values, not exceptions — required for&&/||/?:absorption semantics. Singleton caches (True/False/Null, small ints) mitigate boxing. - Strings are code-point based (CEL
size,charAt,indexOf,substring):StringValuecachesHasSupplementary/CodePointLength; fast path when no surrogates; iterate viaRune. Comparison trap:string.CompareOrdinalsorts UTF-16 units, not code points — use rune-wise compare when surrogates present.string(bytes)rejects invalid UTF-8 (UTF8Encoding(false, true)); lexer rejects\usurrogates/invalid code points. - Timestamp/Duration: custom structs
(long Seconds, int Nanos)— .NET ticks are 100ns but the spec needs nanosecond precision. Hand-written RFC 3339 parse/format. Range checks (0001-01-01..9999-12-31.999999999 / ±315,576,000,000s) →ErrorValue. IANA timezones viaTimeZoneInfo.FindSystemTimeZoneById; fixed offsets(+|-)HH:MMparsed directly. Note spec quirks:getMonth()0-based,getDate()1-based,getDayOfMonth()0-based. - Numerics:
checkedarithmetic →ErrorValue; explicit handling forlong.MinValue / -1,% -1,-long.MinValue. Parser folds unary-into literals (int64.MinValue,-0.0). Cross-type numeric comparison is required at runtime (int/uint/double on one number line) — port cel-go's compare algorithms exactly; never cast long↔double (precision loss > 2^53). NaN:!=is true, all orderings false; conformance matcher treats any-NaN==any-NaN. Map keys: int/uint numerically equal are the same key (customIEqualityComparer<CelValue>); integral-double lookups find int keys.string(double)must match Go'sstrconv.FormatFloat→GoDoubleFormatterwraps .NET shortest-round-trip digits in Go presentation. - Conformance harness: Google.Protobuf C# has no textproto parser →
tools/vendor-conformance.shconverts the 29 textprotos to.binpbviaprotoc --encode=cel.expr.conformance.test.SimpleTestFileat vendoring time (pinned cel-spec tag); binaries checked in; protoc needed only for refresh. Test projects codegencel.expr.*+TestAllTypes(proto2+proto3) viaGrpc.Tools. Runner: one xUnit[Theory]case per test (file/section/name); builds env fromtype_env+container, honorsdisable_check/disable_macros/check_only; proto-aware result comparer (NaN-tolerant, unordered maps). Ratchetingknown-failures.txt: listed+failing ⇒ skip-reported; listed+passing ⇒ CI failure (forces monotonic progress; CI green from day one). - Interpreter is plan-based (cel-go style): plan-time overload binding, constant folding, compiled-regex caching (bounded LRU for dynamic patterns). Comprehension node with accumulator in scoped activation,
iterVar2support, iteration budget. Attribute "maybe" resolution for parse-only namespace semantics (a.b.cas qualified var vs field selects, longest-first per container). - Optionals (
?.,[?],{?k: v},optional.of/none/ofNonZeroValue,orValue/or,optMap/optFlatMap) are technically an extension but have official conformance files — implement asOptionalsLibrary+ parser support. Same forcel.bind, two-var comprehensions (macros2),cel.@block. - Spec currency notes:
int(enum)conversion removed Oct 2025 — don't add it. Canonical protos arecel.expr(google.api.expr.v1alpha1 is deleted legacy). Strings extension is spec'd indoc/extensions/strings.md.
Milestones (each ends with conformance files removed from known-failures.txt)¶
- M0 — Skeleton & pipeline (~1 wk): sln/projects/CI (GitHub Actions), vendor script, pinned protos + 29 .binpb, Grpc.Tools codegen validated (incl. proto2 TestAllTypes — de-risk early), runner enumerating all tests as known-failures. CI green, 0 passing.
- M1 — Lexer/Parser/Macros (~2–3 wks): full lexis (all escape forms, raw/triple/bytes strings, hex/uint literals, reserved words), grammar with trailing commas, literal
-folding, macro expansion to comprehension AST, optional syntax, error positions/recovery. Golden AST tests ported from cel-go parser test table. - M2 — Values + planner + evaluator (parse-only dyn mode) (~3 wks): value hierarchy, traits, cross-type numerics, absorption semantics, comprehensions, activations, arithmetic/logic/comparison/index/
in/size,CelEnv/CelProgramAPI. Green: basic, logic, integer_math, fp_math, lists, parse, plumbing, most comparisons. - M3 — Complete stdlib (~2 wks): all conversions (Go-compatible formatting),
matches()engine, string receivers, timestamp/duration structs + RFC3339 + accessors + temporal arithmetic. Green: string, timestamps, macros, conversions (non-proto), fields (map cases), rest of comparisons. - M4 — Type checker (~2–3 wks): unification, standard decls, container resolution, type/reference maps, checked planning; harness flips to check-enabled. Green: type_deduction, namespace; checked re-run of all prior files.
- M5 — Celly.Protobuf (~2–3 wks): ProtoTypeRegistry, message construction/presence semantics, WKT adapter, enums, Any, AstConverter, proto2 extensions. Green: proto2, proto3, proto2_ext, enums, wrappers, dynamic, remaining fields/conversions.
- M6 — Optionals, unknowns, extensions (~2–3 wks): OptionalsLibrary, partial activations + attribute patterns + unknown merging, strings ext (incl.
format), math, encoders, sets, lists, bindings, two-var comprehensions, regex ext, network ext, cel.@block. Green: all 29 files, known-failures.txt empty. - M7 — Hardening & packaging (~1–2 wks): BenchmarkDotNet suite, lexer/parser fuzzing, PublicApiAnalyzers, XML docs, README/samples, SourceLink, deterministic builds, AOT/trimming audit (core is reflection-free), NuGet publish.
Total ≈ 16–20 engineer-weeks; each milestone is independently shippable progress with green CI.
Top risks & mitigations¶
- Surrogate/code-point string semantics → fast-path design day one; astral-plane test corpus.
- RE2 vs .NET regex gaps → NonBacktracking + small pattern-translation pass (
(?P<name>→(?<name>), pluggableIRegexEngine, documented deviations. - Go double formatting →
GoDoubleFormatter+ ported Go strconv test vectors. - Cross-type compare precision → port cel-go algorithms; property tests vs BigInteger reference.
checkedblind spots (MinValue/-1etc.) → explicit special cases; integer_math conformance gates.- IANA tz on Windows → fixed-offset parser covers conformance; CI matrix incl. windows-latest.
- Proto2 TestAllTypes C# codegen (groups/extensions) → validate in M0 before any impl work.
- Unknowns fidelity → mirror cel-go AttributePattern design; schedule after absorption machinery proven (M6).
- Conformance data drift → pinned tag + re-encode check in CI.
- Class-based value allocation perf → singleton caches + plan-time folding now; struct-wrapper experiment deferred (internal repr, not API break).
Verification¶
dotnet test— unit suites (lexer goldens, parser AST goldens, checker deductions, eval tables, numeric property tests, RFC3339/double-format vectors, surrogate corpus).- Conformance:
dotnet test tests/Celly.Conformance— pass-rate ratchet via known-failures.txt; milestone exit = named files green; final bar = all 29 files, empty skip-list. - Interop: AST↔proto round-trip on every parser golden; Value converter round-trips.
dotnet packboth packages; benchmark suite informational in CI.
First execution step (upon approval)¶
git init, M0 scaffolding: solution + 6 projects, Directory.Build.props, vendor script + pinned cel-spec checkout, protoc conversion of testdata, Grpc.Tools codegen smoke test, conformance runner walking skeleton, GitHub Actions workflow.