From a268c095cd78f847dbf4f64bba0b3e18b2603ca5 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 5 Jan 2026 22:28:40 +0000 Subject: [PATCH 1/4] feat(cel-proto-parser): add CEL proto parser and deparser package - Add new cel-proto-parser package for working with CEL ASTs - Implement CelProtoParser class to parse CEL proto files and generate TypeScript types - Implement deparser to convert CEL AST back to expression strings - Support all CEL expression types: constants, identifiers, field access, function calls, operators, ternary, lists, maps, message construction, comprehensions - Add AST helper generation for constructing CEL AST nodes - Include comprehensive test suite with 34 passing tests - Include CEL syntax.proto fixture from official cel-spec --- packages/cel-proto-parser/README.md | 98 +++ .../__fixtures__/syntax.proto | 416 ++++++++++++ .../__tests__/deparser.test.ts | 369 +++++++++++ packages/cel-proto-parser/jest.config.js | 18 + packages/cel-proto-parser/package.json | 53 ++ packages/cel-proto-parser/scripts/generate.ts | 37 ++ packages/cel-proto-parser/src/ast/enums.ts | 47 ++ packages/cel-proto-parser/src/ast/index.ts | 2 + packages/cel-proto-parser/src/ast/types.ts | 221 +++++++ .../cel-proto-parser/src/deparser/index.ts | 606 ++++++++++++++++++ packages/cel-proto-parser/src/index.ts | 22 + .../cel-proto-parser/src/options/defaults.ts | 80 +++ .../cel-proto-parser/src/options/index.ts | 2 + .../cel-proto-parser/src/options/types.ts | 92 +++ packages/cel-proto-parser/src/parser.ts | 284 ++++++++ packages/cel-proto-parser/src/utils/index.ts | 110 ++++ packages/cel-proto-parser/tsconfig.esm.json | 9 + packages/cel-proto-parser/tsconfig.json | 9 + pnpm-lock.yaml | 106 +++ 19 files changed, 2581 insertions(+) create mode 100644 packages/cel-proto-parser/README.md create mode 100644 packages/cel-proto-parser/__fixtures__/syntax.proto create mode 100644 packages/cel-proto-parser/__tests__/deparser.test.ts create mode 100644 packages/cel-proto-parser/jest.config.js create mode 100644 packages/cel-proto-parser/package.json create mode 100644 packages/cel-proto-parser/scripts/generate.ts create mode 100644 packages/cel-proto-parser/src/ast/enums.ts create mode 100644 packages/cel-proto-parser/src/ast/index.ts create mode 100644 packages/cel-proto-parser/src/ast/types.ts create mode 100644 packages/cel-proto-parser/src/deparser/index.ts create mode 100644 packages/cel-proto-parser/src/index.ts create mode 100644 packages/cel-proto-parser/src/options/defaults.ts create mode 100644 packages/cel-proto-parser/src/options/index.ts create mode 100644 packages/cel-proto-parser/src/options/types.ts create mode 100644 packages/cel-proto-parser/src/parser.ts create mode 100644 packages/cel-proto-parser/src/utils/index.ts create mode 100644 packages/cel-proto-parser/tsconfig.esm.json create mode 100644 packages/cel-proto-parser/tsconfig.json diff --git a/packages/cel-proto-parser/README.md b/packages/cel-proto-parser/README.md new file mode 100644 index 0000000..ec857fd --- /dev/null +++ b/packages/cel-proto-parser/README.md @@ -0,0 +1,98 @@ +# cel-proto-parser + +CEL (Common Expression Language) proto parser and deparser for TypeScript. + +## Overview + +This package provides tools for working with CEL (Common Expression Language) Abstract Syntax Trees (ASTs): + +1. **Proto Parser**: Parse CEL protobuf definitions to generate TypeScript types +2. **Deparser**: Convert CEL AST back to CEL expression strings +3. **AST Helpers**: Factory functions for constructing CEL AST nodes + +## Installation + +```bash +npm install cel-proto-parser +``` + +## Usage + +### Deparser + +Convert CEL AST to expression strings: + +```typescript +import { deparse, Expr } from 'cel-proto-parser'; + +// Simple expression: x > 5 +const expr: Expr = { + callExpr: { + function: '_>_', + args: [ + { identExpr: { name: 'x' } }, + { constExpr: { int64Value: 5 } } + ] + } +}; + +console.log(deparse(expr)); // Output: "x > 5" +``` + +### Proto Parser + +Generate TypeScript types from CEL proto files: + +```typescript +import { CelProtoParser } from 'cel-proto-parser'; + +const parser = new CelProtoParser('path/to/syntax.proto', { + outDir: './generated', + types: { enabled: true }, + enums: { enabled: true }, + utils: { astHelpers: { enabled: true } }, + deparser: { enabled: true } +}); + +parser.write(); +``` + +## CEL Expression Types + +The deparser supports all CEL expression types: + +- **Constants**: `null`, `true`, `false`, integers, floats, strings, bytes +- **Identifiers**: `request`, `user`, etc. +- **Field Access**: `request.auth.claims` +- **Function Calls**: `size(list)`, `str.startsWith("prefix")` +- **Operators**: `+`, `-`, `*`, `/`, `%`, `==`, `!=`, `<`, `<=`, `>`, `>=`, `&&`, `||`, `!`, `-` (unary) +- **Ternary**: `condition ? trueExpr : falseExpr` +- **Index Access**: `list[0]`, `map["key"]` +- **Lists**: `[1, 2, 3]` +- **Maps**: `{"key": value}` +- **Message Construction**: `MyMessage{field: value}` +- **Macros**: `has()`, `all()`, `exists()`, etc. + +## API Reference + +### `deparse(expr: Expr, options?: DeparserOptions): string` + +Converts a CEL AST expression to a string. + +Options: +- `spaces`: Whether to add spaces around operators (default: `true`) + +### `CelProtoParser` + +Parses CEL proto files and generates TypeScript code. + +Constructor options: +- `outDir`: Output directory for generated files +- `types.enabled`: Generate TypeScript interfaces +- `enums.enabled`: Generate TypeScript enums +- `utils.astHelpers.enabled`: Generate AST helper functions +- `deparser.enabled`: Generate deparser module + +## License + +MIT diff --git a/packages/cel-proto-parser/__fixtures__/syntax.proto b/packages/cel-proto-parser/__fixtures__/syntax.proto new file mode 100644 index 0000000..00635e6 --- /dev/null +++ b/packages/cel-proto-parser/__fixtures__/syntax.proto @@ -0,0 +1,416 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package cel.expr; + +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +option cc_enable_arenas = true; +option go_package = "cel.dev/expr"; +option java_multiple_files = true; +option java_outer_classname = "SyntaxProto"; +option java_package = "dev.cel.expr"; + +// A representation of the abstract syntax of the Common Expression Language. + +// An expression together with source information as returned by the parser. +message ParsedExpr { + // The parsed expression. + Expr expr = 2; + + // The source info derived from input that generated the parsed `expr`. + SourceInfo source_info = 3; +} + +// An abstract representation of a common expression. +// +// Expressions are abstractly represented as a collection of identifiers, +// select statements, function calls, literals, and comprehensions. All +// operators with the exception of the '.' operator are modelled as function +// calls. This makes it easy to represent new operators into the existing AST. +// +// All references within expressions must resolve to a +// [Decl][cel.expr.Decl] provided at type-check for an expression to be +// valid. A reference may either be a bare identifier `name` or a qualified +// identifier `google.api.name`. References may either refer to a value or a +// function declaration. +// +// For example, the expression `google.api.name.startsWith('expr')` references +// the declaration `google.api.name` within a +// [Expr.Select][cel.expr.Expr.Select] expression, and the function +// declaration `startsWith`. +message Expr { + // An identifier expression. e.g. `request`. + message Ident { + // Required. Holds a single, unqualified identifier, possibly preceded by a + // '.'. + // + // Qualified names are represented by the + // [Expr.Select][cel.expr.Expr.Select] expression. + string name = 1; + } + + // A field selection expression. e.g. `request.auth`. + message Select { + // Required. The target of the selection expression. + // + // For example, in the select expression `request.auth`, the `request` + // portion of the expression is the `operand`. + Expr operand = 1; + + // Required. The name of the field to select. + // + // For example, in the select expression `request.auth`, the `auth` portion + // of the expression would be the `field`. + string field = 2; + + // Whether the select is to be interpreted as a field presence test. + // + // This results from the macro `has(request.auth)`. + bool test_only = 3; + } + + // A call expression, including calls to predefined functions and operators. + // + // For example, `value == 10`, `size(map_value)`. + message Call { + // The target of an method call-style expression. For example, `x` in + // `x.f()`. + Expr target = 1; + + // Required. The name of the function or method being called. + string function = 2; + + // The arguments. + repeated Expr args = 3; + } + + // A list creation expression. + // + // Lists may either be homogenous, e.g. `[1, 2, 3]`, or heterogeneous, e.g. + // `dyn([1, 'hello', 2.0])` + message CreateList { + // The elements part of the list. + repeated Expr elements = 1; + + // The indices within the elements list which are marked as optional + // elements. + // + // When an optional-typed value is present, the value it contains + // is included in the list. If the optional-typed value is absent, the list + // element is omitted from the CreateList result. + repeated int32 optional_indices = 2; + } + + // A map or message creation expression. + // + // Maps are constructed as `{'key_name': 'value'}`. Message construction is + // similar, but prefixed with a type name and composed of field ids: + // `types.MyType{field_id: 'value'}`. + message CreateStruct { + // Represents an entry. + message Entry { + // Required. An id assigned to this node by the parser which is unique + // in a given expression tree. This is used to associate type + // information and other attributes to the node. + int64 id = 1; + + // The `Entry` key kinds. + oneof key_kind { + // The field key for a message creator statement. + string field_key = 2; + + // The key expression for a map creation statement. + Expr map_key = 3; + } + + // Required. The value assigned to the key. + // + // If the optional_entry field is true, the expression must resolve to an + // optional-typed value. If the optional value is present, the key will be + // set; however, if the optional value is absent, the key will be unset. + Expr value = 4; + + // Whether the key-value pair is optional. + bool optional_entry = 5; + } + + // The type name of the message to be created, empty when creating map + // literals. + string message_name = 1; + + // The entries in the creation expression. + repeated Entry entries = 2; + } + + // A comprehension expression applied to a list or map. + // + // Comprehensions are not part of the core syntax, but enabled with macros. + // A macro matches a specific call signature within a parsed AST and replaces + // the call with an alternate AST block. Macro expansion happens at parse + // time. + // + // The following macros are supported within CEL: + // + // Aggregate type macros may be applied to all elements in a list or all keys + // in a map: + // + // * `all`, `exists`, `exists_one` - test a predicate expression against + // the inputs and return `true` if the predicate is satisfied for all, + // any, or only one value `list.all(x, x < 10)`. + // * `filter` - test a predicate expression against the inputs and return + // the subset of elements which satisfy the predicate: + // `payments.filter(p, p > 1000)`. + // * `map` - apply an expression to all elements in the input and return the + // output aggregate type: `[1, 2, 3].map(i, i * i)`. + // + // The `has(m.x)` macro tests whether the property `x` is present in struct + // `m`. The semantics of this macro depend on the type of `m`. For proto2 + // messages `has(m.x)` is defined as 'defined, but not set`. For proto3, the + // macro tests whether the property is set to its default. For map and struct + // types, the macro tests whether the property `x` is defined on `m`. + // + // Comprehensions for the standard environment macros evaluation can be best + // visualized as the following pseudocode: + // + // ``` + // let `accu_var` = `accu_init` + // for (let `iter_var` in `iter_range`) { + // if (!`loop_condition`) { + // break + // } + // `accu_var` = `loop_step` + // } + // return `result` + // ``` + // + // Comprehensions for the optional V2 macros which support map-to-map + // translation differ slightly from the standard environment macros in that + // they expose both the key or index in addition to the value for each list + // or map entry: + // + // ``` + // let `accu_var` = `accu_init` + // for (let `iter_var`, `iter_var2` in `iter_range`) { + // if (!`loop_condition`) { + // break + // } + // `accu_var` = `loop_step` + // } + // return `result` + // ``` + message Comprehension { + // The name of the first iteration variable. + // For the single iteration variable macros, when iter_range is a list, this + // variable is the list element and when the iter_range is a map, this + // variable is the map key. + string iter_var = 1; + + // The name of the second iteration variable, empty if not set. + // This field is only set for comprehension v2 macros. + string iter_var2 = 8; + + // The range over which the comprehension iterates. + Expr iter_range = 2; + + // The name of the variable used for accumulation of the result. + string accu_var = 3; + + // The initial value of the accumulator. + Expr accu_init = 4; + + // An expression which can contain iter_var, iter_var2, and accu_var. + // + // Returns false when the result has been computed and may be used as + // a hint to short-circuit the remainder of the comprehension. + Expr loop_condition = 5; + + // An expression which can contain iter_var, iter_var2, and accu_var. + // + // Computes the next value of accu_var. + Expr loop_step = 6; + + // An expression which can contain accu_var. + // + // Computes the result. + Expr result = 7; + } + + // Required. An id assigned to this node by the parser which is unique in a + // given expression tree. This is used to associate type information and other + // attributes to a node in the parse tree. + int64 id = 2; + + // Required. Variants of expressions. + oneof expr_kind { + // A constant expression. + Constant const_expr = 3; + + // An identifier expression. + Ident ident_expr = 4; + + // A field selection expression, e.g. `request.auth`. + Select select_expr = 5; + + // A call expression, including calls to predefined functions and operators. + Call call_expr = 6; + + // A list creation expression. + CreateList list_expr = 7; + + // A map or message creation expression. + CreateStruct struct_expr = 8; + + // A comprehension expression. + Comprehension comprehension_expr = 9; + } +} + +// Represents a primitive literal. +// +// Named 'Constant' here for backwards compatibility. +// +// This is similar as the primitives supported in the well-known type +// `google.protobuf.Value`, but richer so it can represent CEL's full range of +// primitives. +// +// Lists and structs are not included as constants as these aggregate types may +// contain [Expr][cel.expr.Expr] elements which require evaluation and +// are thus not constant. +// +// Examples of constants include: `"hello"`, `b'bytes'`, `1u`, `4.2`, `-2`, +// `true`, `null`. +message Constant { + // Required. The valid constant kinds. + oneof constant_kind { + // null value. + google.protobuf.NullValue null_value = 1; + + // boolean value. + bool bool_value = 2; + + // int64 value. + int64 int64_value = 3; + + // uint64 value. + uint64 uint64_value = 4; + + // double value. + double double_value = 5; + + // string value. + string string_value = 6; + + // bytes value. + bytes bytes_value = 7; + + // protobuf.Duration value. + // + // Deprecated: duration is no longer considered a builtin cel type. + google.protobuf.Duration duration_value = 8 [deprecated = true]; + + // protobuf.Timestamp value. + // + // Deprecated: timestamp is no longer considered a builtin cel type. + google.protobuf.Timestamp timestamp_value = 9 [deprecated = true]; + } +} + +// Source information collected at parse time. +message SourceInfo { + // The syntax version of the source, e.g. `cel1`. + string syntax_version = 1; + + // The location name. All position information attached to an expression is + // relative to this location. + // + // The location could be a file, UI element, or similar. For example, + // `acme/app/AnvilPolicy.cel`. + string location = 2; + + // Monotonically increasing list of code point offsets where newlines + // `\n` appear. + // + // The line number of a given position is the index `i` where for a given + // `id` the `line_offsets[i] < id_positions[id] < line_offsets[i+1]`. The + // column may be derived from `id_positions[id] - line_offsets[i]`. + repeated int32 line_offsets = 3; + + // A map from the parse node id (e.g. `Expr.id`) to the code point offset + // within the source. + map positions = 4; + + // A map from the parse node id where a macro replacement was made to the + // call `Expr` that resulted in a macro expansion. + // + // For example, `has(value.field)` is a function call that is replaced by a + // `test_only` field selection in the AST. Likewise, the call + // `list.exists(e, e > 10)` translates to a comprehension expression. The key + // in the map corresponds to the expression id of the expanded macro, and the + // value is the call `Expr` that was replaced. + map macro_calls = 5; + + // A list of tags for extensions that were used while parsing or type checking + // the source expression. For example, optimizations that require special + // runtime support may be specified. + // + // These are used to check feature support between components in separate + // implementations. This can be used to either skip redundant work or + // report an error if the extension is unsupported. + repeated Extension extensions = 6; + + // An extension that was requested for the source expression. + message Extension { + // Version + message Version { + // Major version changes indicate different required support level from + // the required components. + int64 major = 1; + // Minor version changes must not change the observed behavior from + // existing implementations, but may be provided informationally. + int64 minor = 2; + } + + // CEL component specifier. + enum Component { + // Unspecified, default. + COMPONENT_UNSPECIFIED = 0; + // Parser. Converts a CEL string to an AST. + COMPONENT_PARSER = 1; + // Type checker. Checks that references in an AST are defined and types + // agree. + COMPONENT_TYPE_CHECKER = 2; + // Runtime. Evaluates a parsed and optionally checked CEL AST against a + // context. + COMPONENT_RUNTIME = 3; + } + + // Identifier for the extension. Example: constant_folding + string id = 1; + + // If set, the listed components must understand the extension for the + // expression to evaluate correctly. + // + // This field has set semantics, repeated values should be deduplicated. + repeated Component affected_components = 2; + + // Version info. May be skipped if it isn't meaningful for the extension. + // (for example constant_folding might always be v0.0). + Version version = 3; + } +} diff --git a/packages/cel-proto-parser/__tests__/deparser.test.ts b/packages/cel-proto-parser/__tests__/deparser.test.ts new file mode 100644 index 0000000..cccf1da --- /dev/null +++ b/packages/cel-proto-parser/__tests__/deparser.test.ts @@ -0,0 +1,369 @@ +import { deparse, Expr } from '../src/deparser'; + +describe('CEL Deparser', () => { + describe('Constants', () => { + it('deparses null', () => { + const expr: Expr = { constExpr: { nullValue: null } }; + expect(deparse(expr)).toBe('null'); + }); + + it('deparses boolean true', () => { + const expr: Expr = { constExpr: { boolValue: true } }; + expect(deparse(expr)).toBe('true'); + }); + + it('deparses boolean false', () => { + const expr: Expr = { constExpr: { boolValue: false } }; + expect(deparse(expr)).toBe('false'); + }); + + it('deparses int64', () => { + const expr: Expr = { constExpr: { int64Value: 42 } }; + expect(deparse(expr)).toBe('42'); + }); + + it('deparses uint64', () => { + const expr: Expr = { constExpr: { uint64Value: 42 } }; + expect(deparse(expr)).toBe('42u'); + }); + + it('deparses double', () => { + const expr: Expr = { constExpr: { doubleValue: 3.14 } }; + expect(deparse(expr)).toBe('3.14'); + }); + + it('deparses integer double as float', () => { + const expr: Expr = { constExpr: { doubleValue: 5 } }; + expect(deparse(expr)).toBe('5.0'); + }); + + it('deparses string', () => { + const expr: Expr = { constExpr: { stringValue: 'hello' } }; + expect(deparse(expr)).toBe('"hello"'); + }); + + it('escapes special characters in strings', () => { + const expr: Expr = { constExpr: { stringValue: 'hello\nworld' } }; + expect(deparse(expr)).toBe('"hello\\nworld"'); + }); + + it('deparses bytes', () => { + const expr: Expr = { constExpr: { bytesValue: 'abc' } }; + expect(deparse(expr)).toBe('b"abc"'); + }); + }); + + describe('Identifiers', () => { + it('deparses simple identifier', () => { + const expr: Expr = { identExpr: { name: 'request' } }; + expect(deparse(expr)).toBe('request'); + }); + }); + + describe('Select expressions', () => { + it('deparses field access', () => { + const expr: Expr = { + selectExpr: { + operand: { identExpr: { name: 'request' } }, + field: 'auth' + } + }; + expect(deparse(expr)).toBe('request.auth'); + }); + + it('deparses nested field access', () => { + const expr: Expr = { + selectExpr: { + operand: { + selectExpr: { + operand: { identExpr: { name: 'request' } }, + field: 'auth' + } + }, + field: 'claims' + } + }; + expect(deparse(expr)).toBe('request.auth.claims'); + }); + + it('deparses has() macro', () => { + const expr: Expr = { + selectExpr: { + operand: { identExpr: { name: 'request' } }, + field: 'auth', + testOnly: true + } + }; + expect(deparse(expr)).toBe('has(request.auth)'); + }); + }); + + describe('Call expressions', () => { + it('deparses function call', () => { + const expr: Expr = { + callExpr: { + function: 'size', + args: [{ identExpr: { name: 'list' } }] + } + }; + expect(deparse(expr)).toBe('size(list)'); + }); + + it('deparses method call', () => { + const expr: Expr = { + callExpr: { + target: { identExpr: { name: 'str' } }, + function: 'startsWith', + args: [{ constExpr: { stringValue: 'prefix' } }] + } + }; + expect(deparse(expr)).toBe('str.startsWith("prefix")'); + }); + + it('deparses binary operator', () => { + const expr: Expr = { + callExpr: { + function: '_+_', + args: [ + { constExpr: { int64Value: 1 } }, + { constExpr: { int64Value: 2 } } + ] + } + }; + expect(deparse(expr)).toBe('1 + 2'); + }); + + it('deparses comparison operator', () => { + const expr: Expr = { + callExpr: { + function: '_==_', + args: [ + { identExpr: { name: 'x' } }, + { constExpr: { int64Value: 5 } } + ] + } + }; + expect(deparse(expr)).toBe('x == 5'); + }); + + it('deparses logical AND', () => { + const expr: Expr = { + callExpr: { + function: '_&&_', + args: [ + { constExpr: { boolValue: true } }, + { constExpr: { boolValue: false } } + ] + } + }; + expect(deparse(expr)).toBe('true && false'); + }); + + it('deparses logical OR', () => { + const expr: Expr = { + callExpr: { + function: '_||_', + args: [ + { constExpr: { boolValue: true } }, + { constExpr: { boolValue: false } } + ] + } + }; + expect(deparse(expr)).toBe('true || false'); + }); + + it('deparses unary negation', () => { + const expr: Expr = { + callExpr: { + function: '!_', + args: [{ constExpr: { boolValue: true } }] + } + }; + expect(deparse(expr)).toBe('!true'); + }); + + it('deparses unary minus', () => { + const expr: Expr = { + callExpr: { + function: '-_', + args: [{ constExpr: { int64Value: 5 } }] + } + }; + expect(deparse(expr)).toBe('-5'); + }); + + it('deparses ternary conditional', () => { + const expr: Expr = { + callExpr: { + function: '_?_:_', + args: [ + { constExpr: { boolValue: true } }, + { constExpr: { int64Value: 1 } }, + { constExpr: { int64Value: 2 } } + ] + } + }; + expect(deparse(expr)).toBe('true ? 1 : 2'); + }); + + it('deparses index operator', () => { + const expr: Expr = { + callExpr: { + function: '_[_]', + args: [ + { identExpr: { name: 'list' } }, + { constExpr: { int64Value: 0 } } + ] + } + }; + expect(deparse(expr)).toBe('list[0]'); + }); + + it('deparses in operator', () => { + const expr: Expr = { + callExpr: { + function: '@in', + args: [ + { constExpr: { int64Value: 1 } }, + { identExpr: { name: 'list' } } + ] + } + }; + expect(deparse(expr)).toBe('1 in list'); + }); + }); + + describe('List expressions', () => { + it('deparses empty list', () => { + const expr: Expr = { listExpr: { elements: [] } }; + expect(deparse(expr)).toBe('[]'); + }); + + it('deparses list with elements', () => { + const expr: Expr = { + listExpr: { + elements: [ + { constExpr: { int64Value: 1 } }, + { constExpr: { int64Value: 2 } }, + { constExpr: { int64Value: 3 } } + ] + } + }; + expect(deparse(expr)).toBe('[1, 2, 3]'); + }); + + it('deparses list with optional elements', () => { + const expr: Expr = { + listExpr: { + elements: [ + { constExpr: { int64Value: 1 } }, + { constExpr: { int64Value: 2 } } + ], + optionalIndices: [1] + } + }; + expect(deparse(expr)).toBe('[1, ?2]'); + }); + }); + + describe('Struct expressions', () => { + it('deparses empty map', () => { + const expr: Expr = { structExpr: { entries: [] } }; + expect(deparse(expr)).toBe('{}'); + }); + + it('deparses map with entries', () => { + const expr: Expr = { + structExpr: { + entries: [ + { + mapKey: { constExpr: { stringValue: 'key' } }, + value: { constExpr: { int64Value: 1 } } + } + ] + } + }; + expect(deparse(expr)).toBe('{"key": 1}'); + }); + + it('deparses message construction', () => { + const expr: Expr = { + structExpr: { + messageName: 'MyMessage', + entries: [ + { + fieldKey: 'field1', + value: { constExpr: { int64Value: 42 } } + } + ] + } + }; + expect(deparse(expr)).toBe('MyMessage{field1: 42}'); + }); + }); + + describe('Complex expressions', () => { + it('deparses complex boolean expression', () => { + // (x > 5) && (y < 10) + const expr: Expr = { + callExpr: { + function: '_&&_', + args: [ + { + callExpr: { + function: '_>_', + args: [ + { identExpr: { name: 'x' } }, + { constExpr: { int64Value: 5 } } + ] + } + }, + { + callExpr: { + function: '_<_', + args: [ + { identExpr: { name: 'y' } }, + { constExpr: { int64Value: 10 } } + ] + } + } + ] + } + }; + expect(deparse(expr)).toBe('x > 5 && y < 10'); + }); + + it('deparses method chain', () => { + // str.trim().toLowerCase() + const expr: Expr = { + callExpr: { + target: { + callExpr: { + target: { identExpr: { name: 'str' } }, + function: 'trim', + args: [] + } + }, + function: 'toLowerCase', + args: [] + } + }; + expect(deparse(expr)).toBe('str.trim().toLowerCase()'); + }); + }); + + describe('Options', () => { + it('removes spaces when spaces option is false', () => { + const expr: Expr = { + callExpr: { + function: '_+_', + args: [ + { constExpr: { int64Value: 1 } }, + { constExpr: { int64Value: 2 } } + ] + } + }; + expect(deparse(expr, { spaces: false })).toBe('1+2'); + }); + }); +}); diff --git a/packages/cel-proto-parser/jest.config.js b/packages/cel-proto-parser/jest.config.js new file mode 100644 index 0000000..057a942 --- /dev/null +++ b/packages/cel-proto-parser/jest.config.js @@ -0,0 +1,18 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json', + }, + ], + }, + transformIgnorePatterns: [`/node_modules/*`], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'] +}; diff --git a/packages/cel-proto-parser/package.json b/packages/cel-proto-parser/package.json new file mode 100644 index 0000000..549cb19 --- /dev/null +++ b/packages/cel-proto-parser/package.json @@ -0,0 +1,53 @@ +{ + "name": "cel-proto-parser", + "version": "0.1.0", + "description": "CEL (Common Expression Language) proto parser and deparser for TypeScript", + "author": "Constructive ", + "homepage": "https://github.com/constructive-io/dev-utils", + "license": "MIT", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "scripts": { + "copy": "makage assets", + "clean": "makage clean", + "prepublishOnly": "npm run build", + "build": "makage build", + "lint": "eslint . --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:debug": "node --inspect node_modules/.bin/jest --runInBand", + "generate": "ts-node scripts/generate.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/dev-utils" + }, + "keywords": [ + "cel", + "common-expression-language", + "parser", + "deparser", + "ast", + "protobuf" + ], + "bugs": { + "url": "https://github.com/constructive-io/dev-utils/issues" + }, + "dependencies": { + "@babel/generator": "^7.26.9", + "@babel/types": "^7.26.9", + "@launchql/protobufjs": "^7.2.6", + "nested-obj": "workspace:*", + "strfy-js": "workspace:*" + }, + "devDependencies": { + "@types/babel__generator": "^7.6.8", + "makage": "0.1.8", + "ts-node": "^10.9.2" + } +} diff --git a/packages/cel-proto-parser/scripts/generate.ts b/packages/cel-proto-parser/scripts/generate.ts new file mode 100644 index 0000000..f958025 --- /dev/null +++ b/packages/cel-proto-parser/scripts/generate.ts @@ -0,0 +1,37 @@ +import { join, resolve } from 'path'; +import { CelProtoParser, CelProtoParserOptions } from '../src'; + +const inFile: string = join(__dirname, '../__fixtures__/syntax.proto'); +const outDir: string = resolve(join(__dirname, '../src/generated')); + +const options: CelProtoParserOptions = { + outDir, + types: { + enabled: true, + filename: 'types.ts', + optionalFields: true + }, + enums: { + enabled: true, + filename: 'enums.ts', + enumsAsTypeUnion: true + }, + utils: { + astHelpers: { + enabled: true, + filename: 'asts.ts', + typesSource: './types' + } + }, + deparser: { + enabled: true, + filename: 'deparser.ts', + typesSource: './types' + } +}; + +const parser = new CelProtoParser(inFile, options); +parser.write(); + +console.log('CEL types generated successfully!'); +console.log(`Output directory: ${outDir}`); diff --git a/packages/cel-proto-parser/src/ast/enums.ts b/packages/cel-proto-parser/src/ast/enums.ts new file mode 100644 index 0000000..12f7184 --- /dev/null +++ b/packages/cel-proto-parser/src/ast/enums.ts @@ -0,0 +1,47 @@ +import * as t from '@babel/types'; + +interface ProtoEnum { + name: string; + values: Record; +} + +/** + * Convert a protobuf Enum to a TypeScript string union type + */ +export function convertEnumToTsUnionType( + enumNode: ProtoEnum +): t.ExportNamedDeclaration { + const enumName = enumNode.name; + const values = enumNode.values; + + // Create string literal types for each enum value + const unionTypes = Object.keys(values).map((key) => + t.tsLiteralType(t.stringLiteral(key)) + ); + + const typeAlias = t.tsTypeAliasDeclaration( + t.identifier(enumName), + null, + t.tsUnionType(unionTypes) + ); + + return t.exportNamedDeclaration(typeAlias, []); +} + +/** + * Convert a protobuf Enum to a TypeScript enum declaration + */ +export function convertEnumToTsEnumDeclaration( + enumNode: ProtoEnum +): t.ExportNamedDeclaration { + const enumName = enumNode.name; + const values = enumNode.values; + + const members = Object.entries(values).map(([key, value]) => + t.tsEnumMember(t.identifier(key), t.numericLiteral(value)) + ); + + const enumDecl = t.tsEnumDeclaration(t.identifier(enumName), members); + + return t.exportNamedDeclaration(enumDecl, []); +} diff --git a/packages/cel-proto-parser/src/ast/index.ts b/packages/cel-proto-parser/src/ast/index.ts new file mode 100644 index 0000000..dc5ee06 --- /dev/null +++ b/packages/cel-proto-parser/src/ast/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './enums'; diff --git a/packages/cel-proto-parser/src/ast/types.ts b/packages/cel-proto-parser/src/ast/types.ts new file mode 100644 index 0000000..54ddfb1 --- /dev/null +++ b/packages/cel-proto-parser/src/ast/types.ts @@ -0,0 +1,221 @@ +import * as t from '@babel/types'; +import { ResolvedCelProtoParserOptions } from '../options'; +import { toCamelCase, getFieldName, createNamedImport } from '../utils'; + +// Type mapping from protobuf to TypeScript +const PROTO_TO_TS_TYPE: Record = { + string: 'string', + bool: 'boolean', + int32: 'number', + int64: 'bigint', + uint32: 'number', + uint64: 'bigint', + float: 'number', + double: 'number', + bytes: 'Uint8Array' +}; + +/** + * Resolve a protobuf type name to a TypeScript type reference + */ +export function resolveTypeName(typeName: string): t.TSType { + // Handle well-known Google protobuf types + if (typeName.startsWith('google.protobuf.')) { + const shortName = typeName.replace('google.protobuf.', ''); + // Return as a qualified type reference + return t.tsTypeReference( + t.tsQualifiedName( + t.tsQualifiedName(t.identifier('google'), t.identifier('protobuf')), + t.identifier(shortName) + ) + ); + } + + // Handle primitive types + const tsType = PROTO_TO_TS_TYPE[typeName]; + if (tsType) { + switch (tsType) { + case 'string': + return t.tsStringKeyword(); + case 'boolean': + return t.tsBooleanKeyword(); + case 'number': + return t.tsNumberKeyword(); + case 'bigint': + return t.tsBigIntKeyword(); + default: + return t.tsTypeReference(t.identifier(tsType)); + } + } + + // Handle custom types (reference to other interfaces) + return t.tsTypeReference(t.identifier(typeName)); +} + +/** + * Generate import statements for enums + */ +export function generateEnumImports( + enums: Array<{ name: string }>, + source: string +): t.ImportDeclaration { + return createNamedImport( + enums.map((e) => e.name), + source + ); +} + +/** + * Generate a Node union type that includes all AST node types + */ +export function generateNodeUnionType( + types: Array<{ name: string }> +): t.ExportNamedDeclaration { + // Generate wrapped object types: { TypeName: TypeName } + const unionTypeNames = types.map((type) => + t.tsTypeLiteral([ + t.tsPropertySignature( + t.identifier(type.name), + t.tsTypeAnnotation(t.tsTypeReference(t.identifier(type.name))) + ) + ]) + ); + + const unionTypeAlias = t.tsTypeAliasDeclaration( + t.identifier('Node'), + null, + t.tsUnionType(unionTypeNames) + ); + + return t.exportNamedDeclaration(unionTypeAlias, []); +} + +interface ProtoField { + name: string; + type: string; + rule?: string; +} + +interface ProtoType { + name: string; + fields: Record; +} + +/** + * Convert a protobuf Type to a TypeScript interface + */ +export function convertTypeToTsInterface( + type: ProtoType, + options: ResolvedCelProtoParserOptions +): t.ExportNamedDeclaration { + const typeName = type.name; + const fields = type.fields; + + const properties = Object.entries(fields).map(([fieldName, field]) => { + const resolvedType = resolveTypeName(field.type); + const fieldType = + field.rule === 'repeated' ? t.tsArrayType(resolvedType) : resolvedType; + + const prop = t.tsPropertySignature( + t.identifier(getFieldName(field, fieldName)), + t.tsTypeAnnotation(fieldType) + ); + prop.optional = options.types.optionalFields; + return prop; + }); + + const interfaceDecl = t.tsInterfaceDeclaration( + t.identifier(typeName), + null, + [], + t.tsInterfaceBody(properties) + ); + + return t.exportNamedDeclaration(interfaceDecl, []); +} + +/** + * Generate AST helper factory methods + */ +export function generateAstHelperMethods( + types: ProtoType[] +): t.ExportDefaultDeclaration { + const creators = types.map((type) => { + const typeName = type.name; + const param = t.identifier('_p'); + param.optional = true; + param.typeAnnotation = t.tsTypeAnnotation( + t.tsTypeReference(t.identifier(typeName)) + ); + + const fields = type.fields; + + // const _j = {} as TypeName; + const astNodeInit = t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('_j'), + t.tsAsExpression( + t.objectExpression([]), + t.tsTypeReference(t.identifier(typeName)) + ) + ) + ]); + + // _o.set(_j, 'fieldName', _p?.fieldName); + const setStatements = Object.entries(fields).map(([fName, field]) => { + const fieldName = getFieldName(field, fName); + return t.expressionStatement( + t.callExpression( + t.memberExpression(t.identifier('_o'), t.identifier('set')), + [ + t.identifier('_j'), + t.stringLiteral(fieldName), + t.optionalMemberExpression( + t.identifier('_p'), + t.identifier(fieldName), + false, + true + ) + ] + ) + ); + }); + + const methodName = toCamelCase(typeName); + + const method = t.objectMethod( + 'method', + t.identifier(methodName), + [param], + t.blockStatement([ + astNodeInit, + ...setStatements, + t.returnStatement(t.identifier('_j')) + ]) + ); + + method.returnType = t.tsTypeAnnotation( + t.tsTypeReference(t.identifier(typeName)) + ); + + return method; + }); + + return t.exportDefaultDeclaration(t.objectExpression(creators)); +} + +/** + * Generate import specifiers for types + */ +export function generateTypeImportSpecifiers( + types: ProtoType[], + options: ResolvedCelProtoParserOptions +): t.ImportDeclaration { + const importSpecifiers = types.map((type) => + t.importSpecifier(t.identifier(type.name), t.identifier(type.name)) + ); + return t.importDeclaration( + importSpecifiers, + t.stringLiteral(options.utils.astHelpers.typesSource) + ); +} diff --git a/packages/cel-proto-parser/src/deparser/index.ts b/packages/cel-proto-parser/src/deparser/index.ts new file mode 100644 index 0000000..1b2b482 --- /dev/null +++ b/packages/cel-proto-parser/src/deparser/index.ts @@ -0,0 +1,606 @@ +/** + * CEL Deparser - Converts CEL AST back to CEL expression strings + * + * This module provides functionality to convert a CEL Abstract Syntax Tree (AST) + * back into a valid CEL expression string. It handles all CEL constructs including: + * - Literals (int, uint, double, string, bytes, bool, null) + * - Identifiers and field selection + * - Function calls and operators + * - Lists and maps + * - Comprehensions (macros like all, exists, filter, map) + * - Ternary conditionals + */ + +// CEL operator precedence (higher number = higher precedence) +export const PRECEDENCE = { + CONDITIONAL: 1, // ?: + OR: 2, // || + AND: 3, // && + RELATION: 4, // == != < <= > >= in + ADDITION: 5, // + - + MULTIPLICATION: 6, // * / % + UNARY: 7, // ! - + MEMBER: 8, // . [] () + PRIMARY: 9 // literals, identifiers, parentheses +} as const; + +// Binary operators and their precedence +const BINARY_OPERATORS: Record = { + '_||_': { symbol: '||', precedence: PRECEDENCE.OR }, + '_&&_': { symbol: '&&', precedence: PRECEDENCE.AND }, + '_==_': { symbol: '==', precedence: PRECEDENCE.RELATION }, + '_!=_': { symbol: '!=', precedence: PRECEDENCE.RELATION }, + '_<_': { symbol: '<', precedence: PRECEDENCE.RELATION }, + '_<=_': { symbol: '<=', precedence: PRECEDENCE.RELATION }, + '_>_': { symbol: '>', precedence: PRECEDENCE.RELATION }, + '_>=_': { symbol: '>=', precedence: PRECEDENCE.RELATION }, + '@in': { symbol: 'in', precedence: PRECEDENCE.RELATION }, + '_in_': { symbol: 'in', precedence: PRECEDENCE.RELATION }, + '_+_': { symbol: '+', precedence: PRECEDENCE.ADDITION }, + '_-_': { symbol: '-', precedence: PRECEDENCE.ADDITION }, + '_*_': { symbol: '*', precedence: PRECEDENCE.MULTIPLICATION }, + '_/_': { symbol: '/', precedence: PRECEDENCE.MULTIPLICATION }, + '_%%_': { symbol: '%', precedence: PRECEDENCE.MULTIPLICATION } +}; + +// Unary operators +const UNARY_OPERATORS: Record = { + '!_': '!', + '-_': '-' +}; + +/** + * CEL AST Types (simplified for deparser use) + */ +export interface Expr { + id?: bigint | number; + constExpr?: Constant; + identExpr?: Ident; + selectExpr?: Select; + callExpr?: Call; + listExpr?: CreateList; + structExpr?: CreateStruct; + comprehensionExpr?: Comprehension; +} + +export interface Constant { + nullValue?: unknown; + boolValue?: boolean; + int64Value?: bigint | number; + uint64Value?: bigint | number; + doubleValue?: number; + stringValue?: string; + bytesValue?: Uint8Array | string; +} + +export interface Ident { + name?: string; +} + +export interface Select { + operand?: Expr; + field?: string; + testOnly?: boolean; +} + +export interface Call { + target?: Expr; + function?: string; + args?: Expr[]; +} + +export interface CreateList { + elements?: Expr[]; + optionalIndices?: number[]; +} + +export interface CreateStruct { + messageName?: string; + entries?: Entry[]; +} + +export interface Entry { + id?: bigint | number; + fieldKey?: string; + mapKey?: Expr; + value?: Expr; + optionalEntry?: boolean; +} + +export interface Comprehension { + iterVar?: string; + iterVar2?: string; + iterRange?: Expr; + accuVar?: string; + accuInit?: Expr; + loopCondition?: Expr; + loopStep?: Expr; + result?: Expr; +} + +export interface DeparserOptions { + /** Whether to add spaces around operators */ + spaces?: boolean; +} + +/** + * Escape a string for CEL string literal + */ +function escapeString(str: string): string { + return str + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} + +/** + * Escape bytes for CEL bytes literal + */ +function escapeBytes(bytes: Uint8Array | string): string { + if (typeof bytes === 'string') { + // Assume it's already a hex string or similar + return bytes; + } + return Array.from(bytes) + .map((b) => { + if (b >= 32 && b < 127 && b !== 34 && b !== 92) { + return String.fromCharCode(b); + } + return '\\x' + b.toString(16).padStart(2, '0'); + }) + .join(''); +} + +/** + * Get the precedence of an expression + */ +function getExprPrecedence(expr: Expr): number { + if (expr.constExpr) return PRECEDENCE.PRIMARY; + if (expr.identExpr) return PRECEDENCE.PRIMARY; + if (expr.listExpr) return PRECEDENCE.PRIMARY; + if (expr.structExpr) return PRECEDENCE.PRIMARY; + if (expr.selectExpr) return PRECEDENCE.MEMBER; + if (expr.callExpr) { + const fn = expr.callExpr.function || ''; + if (UNARY_OPERATORS[fn]) return PRECEDENCE.UNARY; + if (BINARY_OPERATORS[fn]) return BINARY_OPERATORS[fn].precedence; + if (fn === '_?_:_') return PRECEDENCE.CONDITIONAL; + if (fn === '_[_]') return PRECEDENCE.MEMBER; + return PRECEDENCE.MEMBER; // function call + } + if (expr.comprehensionExpr) return PRECEDENCE.PRIMARY; + return PRECEDENCE.PRIMARY; +} + +/** + * Deparse a CEL expression AST to a string + */ +export function deparse(expr: Expr, options: DeparserOptions = {}): string { + const spaces = options.spaces ?? true; + const sp = spaces ? ' ' : ''; + + function deparseExpr(e: Expr, parentPrecedence: number = 0): string { + // Constant expression + if (e.constExpr) { + return deparseConstant(e.constExpr); + } + + // Identifier expression + if (e.identExpr) { + return e.identExpr.name || ''; + } + + // Select expression (field access) + if (e.selectExpr) { + const operand = e.selectExpr.operand + ? deparseExpr(e.selectExpr.operand, PRECEDENCE.MEMBER) + : ''; + const field = e.selectExpr.field || ''; + + if (e.selectExpr.testOnly) { + // This is the result of has() macro + return `has(${operand}.${field})`; + } + + return `${operand}.${field}`; + } + + // Call expression (function calls and operators) + if (e.callExpr) { + return deparseCall(e.callExpr, parentPrecedence); + } + + // List expression + if (e.listExpr) { + const elements = e.listExpr.elements || []; + const optionalIndices = new Set(e.listExpr.optionalIndices || []); + + const items = elements.map((el, i) => { + const prefix = optionalIndices.has(i) ? '?' : ''; + return prefix + deparseExpr(el); + }); + + return `[${items.join(', ')}]`; + } + + // Struct expression (map or message) + if (e.structExpr) { + return deparseStruct(e.structExpr); + } + + // Comprehension expression (macros) + if (e.comprehensionExpr) { + return deparseComprehension(e.comprehensionExpr); + } + + return ''; + } + + function deparseConstant(c: Constant): string { + if (c.nullValue !== undefined) { + return 'null'; + } + if (c.boolValue !== undefined) { + return c.boolValue ? 'true' : 'false'; + } + if (c.int64Value !== undefined) { + return String(c.int64Value); + } + if (c.uint64Value !== undefined) { + return String(c.uint64Value) + 'u'; + } + if (c.doubleValue !== undefined) { + const d = c.doubleValue; + // Ensure it looks like a float + if (Number.isInteger(d)) { + return d.toFixed(1); + } + return String(d); + } + if (c.stringValue !== undefined) { + return `"${escapeString(c.stringValue)}"`; + } + if (c.bytesValue !== undefined) { + return `b"${escapeBytes(c.bytesValue)}"`; + } + return ''; + } + + function deparseCall(call: Call, parentPrecedence: number): string { + const fn = call.function || ''; + const args = call.args || []; + const target = call.target; + + // Ternary conditional + if (fn === '_?_:_' && args.length === 3) { + const condition = deparseExpr(args[0], PRECEDENCE.CONDITIONAL); + const trueExpr = deparseExpr(args[1], PRECEDENCE.CONDITIONAL); + const falseExpr = deparseExpr(args[2], PRECEDENCE.CONDITIONAL); + const result = `${condition}${sp}?${sp}${trueExpr}${sp}:${sp}${falseExpr}`; + return parentPrecedence > PRECEDENCE.CONDITIONAL + ? `(${result})` + : result; + } + + // Index operator + if (fn === '_[_]' && args.length === 2) { + const obj = deparseExpr(args[0], PRECEDENCE.MEMBER); + const index = deparseExpr(args[1]); + return `${obj}[${index}]`; + } + + // Unary operators + if (UNARY_OPERATORS[fn] && args.length === 1) { + const op = UNARY_OPERATORS[fn]; + const operand = deparseExpr(args[0], PRECEDENCE.UNARY); + return `${op}${operand}`; + } + + // Binary operators + if (BINARY_OPERATORS[fn] && args.length === 2) { + const { symbol, precedence } = BINARY_OPERATORS[fn]; + const left = deparseExpr(args[0], precedence); + const right = deparseExpr(args[1], precedence + 1); // Right associative needs higher precedence + const result = `${left}${sp}${symbol}${sp}${right}`; + return parentPrecedence > precedence ? `(${result})` : result; + } + + // Method call (target.function(args)) + if (target) { + const targetStr = deparseExpr(target, PRECEDENCE.MEMBER); + const argsStr = args.map((a) => deparseExpr(a)).join(', '); + return `${targetStr}.${fn}(${argsStr})`; + } + + // Regular function call + const argsStr = args.map((a) => deparseExpr(a)).join(', '); + return `${fn}(${argsStr})`; + } + + function deparseStruct(struct: CreateStruct): string { + const messageName = struct.messageName || ''; + const entries = struct.entries || []; + + const items = entries.map((entry) => { + const prefix = entry.optionalEntry ? '?' : ''; + + if (entry.fieldKey) { + // Message field + const value = entry.value ? deparseExpr(entry.value) : ''; + return `${prefix}${entry.fieldKey}: ${value}`; + } else if (entry.mapKey) { + // Map entry + const key = deparseExpr(entry.mapKey); + const value = entry.value ? deparseExpr(entry.value) : ''; + return `${prefix}${key}: ${value}`; + } + return ''; + }); + + if (messageName) { + // Message construction + return `${messageName}{${items.join(', ')}}`; + } else { + // Map literal + return `{${items.join(', ')}}`; + } + } + + function deparseComprehension(comp: Comprehension): string { + // Comprehensions are typically the result of macro expansion + // We try to reconstruct the original macro call + + const iterVar = comp.iterVar || ''; + const iterRange = comp.iterRange ? deparseExpr(comp.iterRange) : ''; + const loopCondition = comp.loopCondition + ? deparseExpr(comp.loopCondition) + : ''; + const loopStep = comp.loopStep ? deparseExpr(comp.loopStep) : ''; + const result = comp.result ? deparseExpr(comp.result) : ''; + const accuVar = comp.accuVar || ''; + const accuInit = comp.accuInit ? deparseExpr(comp.accuInit) : ''; + + // Try to detect common macros based on structure + + // exists: __result__ == true, accu starts false, step is || condition + if (accuVar === '__result__' && accuInit === 'false') { + // This looks like exists() + return `${iterRange}.exists(${iterVar}, ${loopStep.replace( + '__result__ || ', + '' + )})`; + } + + // all: __result__ == true, accu starts true, step is && condition + if (accuVar === '__result__' && accuInit === 'true') { + // This looks like all() + return `${iterRange}.all(${iterVar}, ${loopStep.replace( + '__result__ && ', + '' + )})`; + } + + // For other comprehensions, output a comment indicating it's a comprehension + // This is a fallback - ideally we'd reconstruct the original macro + return `/* comprehension: ${iterVar} in ${iterRange} */`; + } + + return deparseExpr(expr); +} + +/** + * Generate TypeScript code for the deparser module + */ +export function generateDeparserCode(): string { + // Return the deparser code as a string that can be written to a file + return `/** + * CEL Deparser - Converts CEL AST back to CEL expression strings + * + * This file was automatically generated by cel-proto-parser. + * DO NOT MODIFY IT BY HAND. + */ + +import type { Expr, Constant, Call, CreateStruct, Comprehension } from './types'; + +// CEL operator precedence (higher number = higher precedence) +export const PRECEDENCE = { + CONDITIONAL: 1, // ?: + OR: 2, // || + AND: 3, // && + RELATION: 4, // == != < <= > >= in + ADDITION: 5, // + - + MULTIPLICATION: 6, // * / % + UNARY: 7, // ! - + MEMBER: 8, // . [] () + PRIMARY: 9 // literals, identifiers, parentheses +} as const; + +// Binary operators and their precedence +const BINARY_OPERATORS: Record = { + '_||_': { symbol: '||', precedence: PRECEDENCE.OR }, + '_&&_': { symbol: '&&', precedence: PRECEDENCE.AND }, + '_==_': { symbol: '==', precedence: PRECEDENCE.RELATION }, + '_!=_': { symbol: '!=', precedence: PRECEDENCE.RELATION }, + '_<_': { symbol: '<', precedence: PRECEDENCE.RELATION }, + '_<=_': { symbol: '<=', precedence: PRECEDENCE.RELATION }, + '_>_': { symbol: '>', precedence: PRECEDENCE.RELATION }, + '_>=_': { symbol: '>=', precedence: PRECEDENCE.RELATION }, + '@in': { symbol: 'in', precedence: PRECEDENCE.RELATION }, + '_in_': { symbol: 'in', precedence: PRECEDENCE.RELATION }, + '_+_': { symbol: '+', precedence: PRECEDENCE.ADDITION }, + '_-_': { symbol: '-', precedence: PRECEDENCE.ADDITION }, + '_*_': { symbol: '*', precedence: PRECEDENCE.MULTIPLICATION }, + '_/_': { symbol: '/', precedence: PRECEDENCE.MULTIPLICATION }, + '_%%_': { symbol: '%', precedence: PRECEDENCE.MULTIPLICATION } +}; + +// Unary operators +const UNARY_OPERATORS: Record = { + '!_': '!', + '-_': '-' +}; + +export interface DeparserOptions { + /** Whether to add spaces around operators */ + spaces?: boolean; +} + +/** + * Escape a string for CEL string literal + */ +function escapeString(str: string): string { + return str + .replace(/\\\\/g, '\\\\\\\\') + .replace(/"/g, '\\\\"') + .replace(/\\n/g, '\\\\n') + .replace(/\\r/g, '\\\\r') + .replace(/\\t/g, '\\\\t'); +} + +/** + * Escape bytes for CEL bytes literal + */ +function escapeBytes(bytes: Uint8Array | string): string { + if (typeof bytes === 'string') { + return bytes; + } + return Array.from(bytes) + .map((b) => { + if (b >= 32 && b < 127 && b !== 34 && b !== 92) { + return String.fromCharCode(b); + } + return '\\\\x' + b.toString(16).padStart(2, '0'); + }) + .join(''); +} + +/** + * Deparse a CEL expression AST to a string + */ +export function deparse(expr: Expr, options: DeparserOptions = {}): string { + const spaces = options.spaces ?? true; + const sp = spaces ? ' ' : ''; + + function deparseExpr(e: Expr, parentPrecedence: number = 0): string { + if (e.constExpr) return deparseConstant(e.constExpr); + if (e.identExpr) return e.identExpr.name || ''; + if (e.selectExpr) { + const operand = e.selectExpr.operand ? deparseExpr(e.selectExpr.operand, PRECEDENCE.MEMBER) : ''; + const field = e.selectExpr.field || ''; + if (e.selectExpr.testOnly) return \`has(\${operand}.\${field})\`; + return \`\${operand}.\${field}\`; + } + if (e.callExpr) return deparseCall(e.callExpr, parentPrecedence); + if (e.listExpr) { + const elements = e.listExpr.elements || []; + const optionalIndices = new Set(e.listExpr.optionalIndices || []); + const items = elements.map((el, i) => { + const prefix = optionalIndices.has(i) ? '?' : ''; + return prefix + deparseExpr(el); + }); + return \`[\${items.join(', ')}]\`; + } + if (e.structExpr) return deparseStruct(e.structExpr); + if (e.comprehensionExpr) return deparseComprehension(e.comprehensionExpr); + return ''; + } + + function deparseConstant(c: Constant): string { + if (c.nullValue !== undefined) return 'null'; + if (c.boolValue !== undefined) return c.boolValue ? 'true' : 'false'; + if (c.int64Value !== undefined) return String(c.int64Value); + if (c.uint64Value !== undefined) return String(c.uint64Value) + 'u'; + if (c.doubleValue !== undefined) { + const d = c.doubleValue; + if (Number.isInteger(d)) return d.toFixed(1); + return String(d); + } + if (c.stringValue !== undefined) return \`"\${escapeString(c.stringValue)}"\`; + if (c.bytesValue !== undefined) return \`b"\${escapeBytes(c.bytesValue)}"\`; + return ''; + } + + function deparseCall(call: Call, parentPrecedence: number): string { + const fn = call.function || ''; + const args = call.args || []; + const target = call.target; + + if (fn === '_?_:_' && args.length === 3) { + const condition = deparseExpr(args[0], PRECEDENCE.CONDITIONAL); + const trueExpr = deparseExpr(args[1], PRECEDENCE.CONDITIONAL); + const falseExpr = deparseExpr(args[2], PRECEDENCE.CONDITIONAL); + const result = \`\${condition}\${sp}?\${sp}\${trueExpr}\${sp}:\${sp}\${falseExpr}\`; + return parentPrecedence > PRECEDENCE.CONDITIONAL ? \`(\${result})\` : result; + } + + if (fn === '_[_]' && args.length === 2) { + const obj = deparseExpr(args[0], PRECEDENCE.MEMBER); + const index = deparseExpr(args[1]); + return \`\${obj}[\${index}]\`; + } + + if (UNARY_OPERATORS[fn] && args.length === 1) { + const op = UNARY_OPERATORS[fn]; + const operand = deparseExpr(args[0], PRECEDENCE.UNARY); + return \`\${op}\${operand}\`; + } + + if (BINARY_OPERATORS[fn] && args.length === 2) { + const { symbol, precedence } = BINARY_OPERATORS[fn]; + const left = deparseExpr(args[0], precedence); + const right = deparseExpr(args[1], precedence + 1); + const result = \`\${left}\${sp}\${symbol}\${sp}\${right}\`; + return parentPrecedence > precedence ? \`(\${result})\` : result; + } + + if (target) { + const targetStr = deparseExpr(target, PRECEDENCE.MEMBER); + const argsStr = args.map((a) => deparseExpr(a)).join(', '); + return \`\${targetStr}.\${fn}(\${argsStr})\`; + } + + const argsStr = args.map((a) => deparseExpr(a)).join(', '); + return \`\${fn}(\${argsStr})\`; + } + + function deparseStruct(struct: CreateStruct): string { + const messageName = struct.messageName || ''; + const entries = struct.entries || []; + const items = entries.map((entry) => { + const prefix = entry.optionalEntry ? '?' : ''; + if (entry.fieldKey) { + const value = entry.value ? deparseExpr(entry.value) : ''; + return \`\${prefix}\${entry.fieldKey}: \${value}\`; + } else if (entry.mapKey) { + const key = deparseExpr(entry.mapKey); + const value = entry.value ? deparseExpr(entry.value) : ''; + return \`\${prefix}\${key}: \${value}\`; + } + return ''; + }); + if (messageName) return \`\${messageName}{\${items.join(', ')}}\`; + return \`{\${items.join(', ')}}\`; + } + + function deparseComprehension(comp: Comprehension): string { + const iterVar = comp.iterVar || ''; + const iterRange = comp.iterRange ? deparseExpr(comp.iterRange) : ''; + const accuVar = comp.accuVar || ''; + const accuInit = comp.accuInit ? deparseExpr(comp.accuInit) : ''; + const loopStep = comp.loopStep ? deparseExpr(comp.loopStep) : ''; + + if (accuVar === '__result__' && accuInit === 'false') { + return \`\${iterRange}.exists(\${iterVar}, \${loopStep.replace('__result__ || ', '')})\`; + } + if (accuVar === '__result__' && accuInit === 'true') { + return \`\${iterRange}.all(\${iterVar}, \${loopStep.replace('__result__ && ', '')})\`; + } + return \`/* comprehension: \${iterVar} in \${iterRange} */\`; + } + + return deparseExpr(expr); +} +`; +} diff --git a/packages/cel-proto-parser/src/index.ts b/packages/cel-proto-parser/src/index.ts new file mode 100644 index 0000000..806e13f --- /dev/null +++ b/packages/cel-proto-parser/src/index.ts @@ -0,0 +1,22 @@ +export { CelProtoParser } from './parser'; +export { + CelProtoParserOptions, + ResolvedCelProtoParserOptions, + getOptionsWithDefaults +} from './options'; +export { + deparse, + PRECEDENCE, + DeparserOptions, + Expr, + Constant, + Ident, + Select, + Call, + CreateList, + CreateStruct, + Entry, + Comprehension +} from './deparser'; +export * from './ast'; +export * from './utils'; diff --git a/packages/cel-proto-parser/src/options/defaults.ts b/packages/cel-proto-parser/src/options/defaults.ts new file mode 100644 index 0000000..4e36eb2 --- /dev/null +++ b/packages/cel-proto-parser/src/options/defaults.ts @@ -0,0 +1,80 @@ +import { CelProtoParserOptions, ResolvedCelProtoParserOptions } from './types'; + +const defaultOptions: ResolvedCelProtoParserOptions = { + outDir: process.cwd() + '/out', + exclude: [], + parser: { + keepCase: false + }, + types: { + enabled: true, + filename: 'types.ts', + optionalFields: true, + enumsSource: './enums' + }, + enums: { + enabled: true, + filename: 'enums.ts', + enumsAsTypeUnion: true + }, + utils: { + astHelpers: { + enabled: true, + filename: 'asts.ts', + typesSource: './types' + } + }, + deparser: { + enabled: true, + filename: 'deparser.ts', + typesSource: './types' + } +}; + +export function getOptionsWithDefaults( + options?: CelProtoParserOptions +): ResolvedCelProtoParserOptions { + if (!options) { + return { ...defaultOptions }; + } + + return { + outDir: options.outDir ?? defaultOptions.outDir, + exclude: options.exclude ?? defaultOptions.exclude, + parser: { + keepCase: options.parser?.keepCase ?? defaultOptions.parser.keepCase + }, + types: { + enabled: options.types?.enabled ?? defaultOptions.types.enabled, + filename: options.types?.filename ?? defaultOptions.types.filename, + optionalFields: + options.types?.optionalFields ?? defaultOptions.types.optionalFields, + enumsSource: options.types?.enumsSource ?? defaultOptions.types.enumsSource + }, + enums: { + enabled: options.enums?.enabled ?? defaultOptions.enums.enabled, + filename: options.enums?.filename ?? defaultOptions.enums.filename, + enumsAsTypeUnion: + options.enums?.enumsAsTypeUnion ?? defaultOptions.enums.enumsAsTypeUnion + }, + utils: { + astHelpers: { + enabled: + options.utils?.astHelpers?.enabled ?? + defaultOptions.utils.astHelpers.enabled, + filename: + options.utils?.astHelpers?.filename ?? + defaultOptions.utils.astHelpers.filename, + typesSource: + options.utils?.astHelpers?.typesSource ?? + defaultOptions.utils.astHelpers.typesSource + } + }, + deparser: { + enabled: options.deparser?.enabled ?? defaultOptions.deparser.enabled, + filename: options.deparser?.filename ?? defaultOptions.deparser.filename, + typesSource: + options.deparser?.typesSource ?? defaultOptions.deparser.typesSource + } + }; +} diff --git a/packages/cel-proto-parser/src/options/index.ts b/packages/cel-proto-parser/src/options/index.ts new file mode 100644 index 0000000..c7098fd --- /dev/null +++ b/packages/cel-proto-parser/src/options/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './defaults'; diff --git a/packages/cel-proto-parser/src/options/types.ts b/packages/cel-proto-parser/src/options/types.ts new file mode 100644 index 0000000..f01db39 --- /dev/null +++ b/packages/cel-proto-parser/src/options/types.ts @@ -0,0 +1,92 @@ +/** + * Options for CEL proto parser + */ + +export interface CelProtoParserOptions { + /** Output directory for generated files */ + outDir: string; + + /** List of type or enum names to exclude during processing */ + exclude?: string[]; + + /** Parser options for protobufjs */ + parser?: { + keepCase?: boolean; + }; + + /** Type generation options */ + types?: { + /** Whether to generate TypeScript interfaces */ + enabled?: boolean; + /** Filename for generated types */ + filename?: string; + /** Whether all fields should be optional */ + optionalFields?: boolean; + /** Source path for enum imports */ + enumsSource?: string; + }; + + /** Enum generation options */ + enums?: { + /** Whether to generate TypeScript enums */ + enabled?: boolean; + /** Filename for generated enums */ + filename?: string; + /** Use string unions instead of enums */ + enumsAsTypeUnion?: boolean; + }; + + /** Utility generation options */ + utils?: { + /** AST helper generation options */ + astHelpers?: { + /** Whether to generate AST helpers */ + enabled?: boolean; + /** Filename for generated helpers */ + filename?: string; + /** Source path for type imports */ + typesSource?: string; + }; + }; + + /** Deparser generation options */ + deparser?: { + /** Whether to generate deparser */ + enabled?: boolean; + /** Filename for generated deparser */ + filename?: string; + /** Source path for type imports */ + typesSource?: string; + }; +} + +export interface ResolvedCelProtoParserOptions { + outDir: string; + exclude: string[]; + parser: { + keepCase: boolean; + }; + types: { + enabled: boolean; + filename: string; + optionalFields: boolean; + enumsSource: string; + }; + enums: { + enabled: boolean; + filename: string; + enumsAsTypeUnion: boolean; + }; + utils: { + astHelpers: { + enabled: boolean; + filename: string; + typesSource: string; + }; + }; + deparser: { + enabled: boolean; + filename: string; + typesSource: string; + }; +} diff --git a/packages/cel-proto-parser/src/parser.ts b/packages/cel-proto-parser/src/parser.ts new file mode 100644 index 0000000..a595304 --- /dev/null +++ b/packages/cel-proto-parser/src/parser.ts @@ -0,0 +1,284 @@ +import { Root, parse } from '@launchql/protobufjs'; +import { readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import * as t from '@babel/types'; +import generate from '@babel/generator'; + +import { + CelProtoParserOptions, + ResolvedCelProtoParserOptions, + getOptionsWithDefaults +} from './options'; +import { + convertTypeToTsInterface, + generateNodeUnionType, + generateAstHelperMethods, + generateTypeImportSpecifiers, + generateEnumImports +} from './ast'; +import { convertEnumToTsUnionType, convertEnumToTsEnumDeclaration } from './ast/enums'; +import { createDefaultImport, convertAstToCode } from './utils'; +import { generateDeparserCode } from './deparser'; + +const GENERATED_HEADER = `/** +* This file was automatically generated by cel-proto-parser. +* DO NOT MODIFY IT BY HAND. Instead, modify the source proto file, +* and run the cel-proto-parser generate command to regenerate this file. +*/`; + +interface ProtoField { + name: string; + type: string; + rule?: string; +} + +interface ProtoType { + name: string; + fields: Record; + nested?: Record; +} + +interface ProtoEnum { + name: string; + values: Record; +} + +function isProtoType(obj: ProtoType | ProtoEnum): obj is ProtoType { + return 'fields' in obj; +} + +function isProtoEnum(obj: ProtoType | ProtoEnum): obj is ProtoEnum { + return 'values' in obj && !('fields' in obj); +} + +export class CelProtoParser { + private protoPath: string; + private options: ResolvedCelProtoParserOptions; + private root: Root | null = null; + private types: ProtoType[] = []; + private enums: ProtoEnum[] = []; + + constructor(protoPath: string, options?: CelProtoParserOptions) { + this.protoPath = protoPath; + this.options = getOptionsWithDefaults(options); + } + + /** + * Parse the proto file and extract types and enums + */ + parse(): void { + const protoContent = readFileSync(this.protoPath, 'utf-8'); + + this.root = parse(protoContent, { + keepCase: this.options.parser.keepCase + }).root; + + this.extractTypesAndEnums(this.root); + } + + /** + * Extract types and enums from the parsed proto root + */ + private extractTypesAndEnums(root: Root): void { + const exclude = new Set(this.options.exclude); + + // Helper to recursively extract from nested structures + const extract = (obj: Record, prefix: string = '') => { + for (const [key, value] of Object.entries(obj)) { + if (!value || typeof value !== 'object') continue; + + const fullName = prefix ? `${prefix}.${key}` : key; + + // Skip excluded types + if (exclude.has(key) || exclude.has(fullName)) continue; + + const item = value as ProtoType | ProtoEnum; + + if (isProtoType(item)) { + // It's a message type + this.types.push({ + name: key, + fields: this.normalizeFields(item.fields), + nested: item.nested + }); + + // Extract nested types + if (item.nested) { + extract(item.nested as Record, fullName); + } + } else if (isProtoEnum(item)) { + // It's an enum + this.enums.push({ + name: key, + values: item.values + }); + } + } + }; + + // Start extraction from root.nested + if (root.nested) { + extract(root.nested as Record); + } + } + + /** + * Normalize fields to ensure consistent structure + */ + private normalizeFields( + fields: Record | undefined + ): Record { + if (!fields) return {}; + + const normalized: Record = {}; + for (const [key, field] of Object.entries(fields)) { + normalized[key] = { + name: field.name || key, + type: field.type, + rule: field.rule + }; + } + return normalized; + } + + /** + * Generate all output files + */ + write(): void { + if (!this.root) { + this.parse(); + } + + const { outDir } = this.options; + mkdirSync(outDir, { recursive: true }); + + if (this.options.enums.enabled) { + this.writeEnums(); + } + + if (this.options.types.enabled) { + this.writeTypes(); + } + + if (this.options.utils.astHelpers.enabled) { + this.writeAstHelpers(); + } + + if (this.options.deparser.enabled) { + this.writeDeparser(); + } + } + + /** + * Write enum definitions to file + */ + private writeEnums(): void { + const { outDir, enums: enumOptions } = this.options; + const filePath = join(outDir, enumOptions.filename); + + const statements: t.Statement[] = []; + + for (const enumNode of this.enums) { + if (enumOptions.enumsAsTypeUnion) { + statements.push(convertEnumToTsUnionType(enumNode)); + } else { + statements.push(convertEnumToTsEnumDeclaration(enumNode)); + } + } + + const code = this.generateCode(statements); + writeFileSync(filePath, code, 'utf-8'); + } + + /** + * Write type definitions to file + */ + private writeTypes(): void { + const { outDir, types: typeOptions, enums: enumOptions } = this.options; + const filePath = join(outDir, typeOptions.filename); + + const statements: t.Statement[] = []; + + // Add enum imports if there are enums + if (this.enums.length > 0 && enumOptions.enabled) { + const enumImport = generateEnumImports( + this.enums, + typeOptions.enumsSource + ); + statements.push(enumImport); + } + + // Generate Node union type + if (this.types.length > 0) { + statements.push(generateNodeUnionType(this.types)); + } + + // Generate interfaces for each type + for (const type of this.types) { + statements.push(convertTypeToTsInterface(type, this.options)); + } + + const code = this.generateCode(statements); + writeFileSync(filePath, code, 'utf-8'); + } + + /** + * Write AST helper functions to file + */ + private writeAstHelpers(): void { + const { outDir, utils } = this.options; + const filePath = join(outDir, utils.astHelpers.filename); + + const statements: t.Statement[] = []; + + // Add nested-obj import + statements.push(createDefaultImport('_o', 'nested-obj')); + + // Add type imports + if (this.types.length > 0) { + statements.push(generateTypeImportSpecifiers(this.types, this.options)); + } + + // Generate helper methods + if (this.types.length > 0) { + statements.push(generateAstHelperMethods(this.types)); + } + + const code = this.generateCode(statements); + writeFileSync(filePath, code, 'utf-8'); + } + + /** + * Write deparser module to file + */ + private writeDeparser(): void { + const { outDir, deparser: deparserOptions } = this.options; + const filePath = join(outDir, deparserOptions.filename); + + const code = generateDeparserCode(); + writeFileSync(filePath, code, 'utf-8'); + } + + /** + * Generate TypeScript code from AST statements + */ + private generateCode(statements: t.Statement[]): string { + const program = t.program(statements); + const { code } = generate(program, { comments: true }); + return `${GENERATED_HEADER}\n${code}\n`; + } + + /** + * Get the parsed types + */ + getTypes(): ProtoType[] { + return this.types; + } + + /** + * Get the parsed enums + */ + getEnums(): ProtoEnum[] { + return this.enums; + } +} diff --git a/packages/cel-proto-parser/src/utils/index.ts b/packages/cel-proto-parser/src/utils/index.ts new file mode 100644 index 0000000..ed6c32a --- /dev/null +++ b/packages/cel-proto-parser/src/utils/index.ts @@ -0,0 +1,110 @@ +import * as t from '@babel/types'; +import generate from '@babel/generator'; +import { mkdirSync, writeFileSync } from 'fs'; +import { dirname } from 'path'; +import { ResolvedCelProtoParserOptions } from '../options'; + +/** + * Convert a string to camelCase + */ +export function toCamelCase(str: string): string { + return str.charAt(0).toLowerCase() + str.slice(1); +} + +/** + * Convert a string to PascalCase + */ +export function toPascalCase(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +/** + * Get the field name, handling protobuf naming conventions + */ +export function getFieldName(field: { name: string }, fallback: string): string { + return field.name || fallback; +} + +/** + * Convert Babel AST nodes to TypeScript code string + */ +export function convertAstToCode( + nodes: t.Statement | t.Statement[] +): string { + const program = t.program(Array.isArray(nodes) ? nodes : [nodes]); + const { code } = generate(program, { + comments: true + }); + return code; +} + +/** + * Create a named import statement + */ +export function createNamedImport( + names: string[], + source: string +): t.ImportDeclaration { + const specifiers = names.map((name) => + t.importSpecifier(t.identifier(name), t.identifier(name)) + ); + return t.importDeclaration(specifiers, t.stringLiteral(source)); +} + +/** + * Create a default import statement + */ +export function createDefaultImport( + name: string, + source: string +): t.ImportDeclaration { + return t.importDeclaration( + [t.importDefaultSpecifier(t.identifier(name))], + t.stringLiteral(source) + ); +} + +/** + * Write content to a file, creating directories as needed + */ +export function writeFileToDisk( + filePath: string, + content: string, + _options: ResolvedCelProtoParserOptions +): void { + const dir = dirname(filePath); + mkdirSync(dir, { recursive: true }); + writeFileSync(filePath, content, 'utf-8'); +} + +/** + * Clone a protobuf node and add a name property + */ +export function cloneAndNameNode( + node: T, + name: string +): T & { name: string } { + return { ...node, name }; +} + +/** + * Strip file extension from a path + */ +export function stripExtension(filename: string): string { + return filename.replace(/\.[^/.]+$/, ''); +} + +/** + * Ensure a filename has the correct extension + */ +export function ensureExtension(filename: string, ext: string): string { + if (!ext.startsWith('.')) { + ext = '.' + ext; + } + if (filename.endsWith(ext)) { + return filename; + } + // Remove any existing extension and add the new one + const base = stripExtension(filename); + return base + ext; +} diff --git a/packages/cel-proto-parser/tsconfig.esm.json b/packages/cel-proto-parser/tsconfig.esm.json new file mode 100644 index 0000000..800d750 --- /dev/null +++ b/packages/cel-proto-parser/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/esm", + "module": "es2022", + "rootDir": "src/", + "declaration": false + } +} diff --git a/packages/cel-proto-parser/tsconfig.json b/packages/cel-proto-parser/tsconfig.json new file mode 100644 index 0000000..1a9d569 --- /dev/null +++ b/packages/cel-proto-parser/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src/" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c556389..8fb9eee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,35 @@ importers: version: 0.1.8 publishDirectory: dist + packages/cel-proto-parser: + dependencies: + '@babel/generator': + specifier: ^7.26.9 + version: 7.28.5 + '@babel/types': + specifier: ^7.26.9 + version: 7.28.5 + '@launchql/protobufjs': + specifier: ^7.2.6 + version: 7.2.6 + nested-obj: + specifier: workspace:* + version: link:../nested-obj/dist + strfy-js: + specifier: workspace:* + version: link:../strfy-js/dist + devDependencies: + '@types/babel__generator': + specifier: ^7.6.8 + version: 7.27.0 + makage: + specifier: 0.1.8 + version: 0.1.8 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) + publishDirectory: dist + packages/clean-ansi: devDependencies: makage: @@ -719,6 +748,10 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@launchql/protobufjs@7.2.6': + resolution: {integrity: sha512-vwi1nG2/heVFsIMHQU1KxTjUp5c757CTtRAZn/jutApCkFlle1iv8tzM/DHlSZJKDldxaYqnNYTg0pTyp8Bbtg==} + engines: {node: '>=12.0.0'} + '@lerna/create@8.2.4': resolution: {integrity: sha512-A8AlzetnS2WIuhijdAzKUyFpR5YbLLfV3luQ4lzBgIBgRfuoBDZeF+RSZPhra+7A6/zTUlrbhKZIOi/MNhqgvQ==} engines: {node: '>=18.0.0'} @@ -927,6 +960,36 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@sigstore/bundle@2.3.2': resolution: {integrity: sha512-wueKWDk70QixNLB363yHc2D2ItTgYiMTdPwK8D9dKQMR3ZQ0c35IxP5xnwQ8cNLoCgCRcHf14kE+CLIvNX1zmA==} engines: {node: ^16.14.0 || >=18.0.0} @@ -2657,6 +2720,9 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -4365,6 +4431,21 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@launchql/protobufjs@7.2.6': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.19.1 + long: 5.3.2 + '@lerna/create@8.2.4(@types/node@22.19.1)(encoding@0.1.13)(typescript@5.9.3)': dependencies: '@npmcli/arborist': 7.5.4 @@ -4714,6 +4795,29 @@ snapshots: '@pkgr/core@0.2.9': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@sigstore/bundle@2.3.2': dependencies: '@sigstore/protobuf-specs': 0.3.3 @@ -6736,6 +6840,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@5.3.2: {} + lru-cache@10.4.3: {} lru-cache@11.2.2: {} From 23fffba40fdfc34eb51811bfc1e06683ac39bfb2 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 5 Jan 2026 22:50:12 +0000 Subject: [PATCH 2/4] feat(cel-proto-parser): add round-trip testing utilities and AST converter - Add @marcbachmann/cel-js as dependency for CEL parsing - Create AST converter from @marcbachmann/cel-js format to CEL proto format - Create TestUtil class (CelTest) for round-trip testing similar to pgsql-parser - Add CEL expression fixtures for testing - Add comprehensive converter and integration tests (31 new tests) - Add test-roundtrip.ts script for ESM-based round-trip testing - Export convertToProtoExpr and MarcAstNode from main index --- .../expressions/cel-expressions.txt | 111 +++++ .../__tests__/roundtrip.test.ts | 467 ++++++++++++++++++ packages/cel-proto-parser/package.json | 1 + .../scripts/test-roundtrip.ts | 173 +++++++ .../cel-proto-parser/src/converter/index.ts | 273 ++++++++++ packages/cel-proto-parser/src/index.ts | 1 + packages/cel-proto-parser/test-utils/index.ts | 274 ++++++++++ pnpm-lock.yaml | 10 + 8 files changed, 1310 insertions(+) create mode 100644 packages/cel-proto-parser/__fixtures__/expressions/cel-expressions.txt create mode 100644 packages/cel-proto-parser/__tests__/roundtrip.test.ts create mode 100644 packages/cel-proto-parser/scripts/test-roundtrip.ts create mode 100644 packages/cel-proto-parser/src/converter/index.ts create mode 100644 packages/cel-proto-parser/test-utils/index.ts diff --git a/packages/cel-proto-parser/__fixtures__/expressions/cel-expressions.txt b/packages/cel-proto-parser/__fixtures__/expressions/cel-expressions.txt new file mode 100644 index 0000000..b74fef1 --- /dev/null +++ b/packages/cel-proto-parser/__fixtures__/expressions/cel-expressions.txt @@ -0,0 +1,111 @@ +# CEL Expression Fixtures +# Each line is a CEL expression to test for round-trip parsing +# Lines starting with # are comments + +# Literals +1 +42 +-5 +3.14 +-2.5 +1.0 +true +false +null +"hello" +"hello world" +"with \"quotes\"" +"with\nnewline" +"" + +# Unsigned integers +1u +42u +0u + +# Identifiers +x +foo +request +_underscore +camelCase + +# Field access +request.auth +request.auth.claims +request.auth.claims.email +a.b.c.d + +# Arithmetic operators +1 + 2 +a + b +x - y +a * b +x / y +a % b +1 + 2 * 3 +(1 + 2) * 3 + +# Comparison operators +a == b +x != y +a < b +a <= b +a > b +a >= b + +# Logical operators +a && b +x || y +!a +a && b && c +a || b || c +a && b || c +(a || b) && c + +# Ternary conditional +x ? 1 : 2 +a == b ? "yes" : "no" +x > 0 ? x : -x + +# List literals +[] +[1] +[1, 2, 3] +[a, b, c] +["a", "b", "c"] + +# Map literals +{} +{"a": 1} +{"a": 1, "b": 2} +{1: "one", 2: "two"} + +# Index access +list[0] +map["key"] +a[b] +list[i + 1] + +# Function calls +size(list) +int(x) +string(42) +type(x) + +# Method calls +list.size() +str.contains("test") +list.map(x, x * 2) + +# In operator +x in list +"admin" in roles +key in map + +# Complex expressions +request.auth.claims.email == "admin@example.com" +size(request.body) > 0 && request.method == "POST" +user.age >= 18 && "admin" in user.roles +items.all(x, x > 0) +items.exists(x, x == target) diff --git a/packages/cel-proto-parser/__tests__/roundtrip.test.ts b/packages/cel-proto-parser/__tests__/roundtrip.test.ts new file mode 100644 index 0000000..47700ce --- /dev/null +++ b/packages/cel-proto-parser/__tests__/roundtrip.test.ts @@ -0,0 +1,467 @@ +/** + * CEL Round-Trip Tests + * + * Tests the CEL AST converter and deparser functionality. + * + * Note: Full round-trip testing with @marcbachmann/cel-js requires ESM support. + * Use the scripts/test-roundtrip.ts script for comprehensive round-trip testing. + * + * These tests verify the converter and deparser work correctly with + * manually constructed ASTs that match the @marcbachmann/cel-js format. + */ + +import { deparse, Expr } from '../src/deparser'; +import { convertToProtoExpr, MarcAstNode } from '../src/converter'; + +describe('CEL AST Converter', () => { + describe('Value Conversion', () => { + it('converts integer value', () => { + const marcAst: MarcAstNode = { + op: 'value', + args: 42n + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.constExpr).toBeDefined(); + expect(expr.constExpr?.int64Value).toBe(42n); + }); + + it('converts string value', () => { + const marcAst: MarcAstNode = { + op: 'value', + args: 'hello' + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.constExpr).toBeDefined(); + expect(expr.constExpr?.stringValue).toBe('hello'); + }); + + it('converts boolean value', () => { + const marcAst: MarcAstNode = { + op: 'value', + args: true + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.constExpr).toBeDefined(); + expect(expr.constExpr?.boolValue).toBe(true); + }); + + it('converts null value', () => { + const marcAst: MarcAstNode = { + op: 'value', + args: null + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.constExpr).toBeDefined(); + expect(expr.constExpr?.nullValue).toBe(null); + }); + + it('converts double value', () => { + const marcAst: MarcAstNode = { + op: 'value', + args: 3.14 + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.constExpr).toBeDefined(); + expect(expr.constExpr?.doubleValue).toBe(3.14); + }); + }); + + describe('Identifier Conversion', () => { + it('converts identifier', () => { + const marcAst: MarcAstNode = { + op: 'id', + args: 'request' + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.identExpr).toBeDefined(); + expect(expr.identExpr?.name).toBe('request'); + }); + }); + + describe('Field Access Conversion', () => { + it('converts field access', () => { + const marcAst: MarcAstNode = { + op: '.', + args: [ + { op: 'id', args: 'request' }, + 'auth' + ] + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.selectExpr).toBeDefined(); + expect(expr.selectExpr?.field).toBe('auth'); + expect(expr.selectExpr?.operand?.identExpr?.name).toBe('request'); + }); + + it('converts nested field access', () => { + const marcAst: MarcAstNode = { + op: '.', + args: [ + { + op: '.', + args: [ + { op: 'id', args: 'request' }, + 'auth' + ] + }, + 'claims' + ] + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.selectExpr).toBeDefined(); + expect(expr.selectExpr?.field).toBe('claims'); + }); + }); + + describe('Binary Operator Conversion', () => { + it('converts addition', () => { + const marcAst: MarcAstNode = { + op: '+', + args: [ + { op: 'value', args: 1n }, + { op: 'value', args: 2n } + ] + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.callExpr).toBeDefined(); + expect(expr.callExpr?.function).toBe('_+_'); + expect(expr.callExpr?.args).toHaveLength(2); + }); + + it('converts comparison', () => { + const marcAst: MarcAstNode = { + op: '==', + args: [ + { op: 'id', args: 'a' }, + { op: 'id', args: 'b' } + ] + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.callExpr).toBeDefined(); + expect(expr.callExpr?.function).toBe('_==_'); + }); + + it('converts logical and', () => { + const marcAst: MarcAstNode = { + op: '&&', + args: [ + { op: 'id', args: 'a' }, + { op: 'id', args: 'b' } + ] + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.callExpr).toBeDefined(); + expect(expr.callExpr?.function).toBe('_&&_'); + }); + + it('converts logical or', () => { + const marcAst: MarcAstNode = { + op: '||', + args: [ + { op: 'id', args: 'a' }, + { op: 'id', args: 'b' } + ] + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.callExpr).toBeDefined(); + expect(expr.callExpr?.function).toBe('_||_'); + }); + + it('converts in operator', () => { + const marcAst: MarcAstNode = { + op: 'in', + args: [ + { op: 'id', args: 'x' }, + { op: 'id', args: 'list' } + ] + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.callExpr).toBeDefined(); + expect(expr.callExpr?.function).toBe('_in_'); + }); + }); + + describe('Unary Operator Conversion', () => { + it('converts negation', () => { + const marcAst: MarcAstNode = { + op: '!', + args: [{ op: 'id', args: 'a' }] + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.callExpr).toBeDefined(); + expect(expr.callExpr?.function).toBe('!_'); + }); + }); + + describe('Ternary Conversion', () => { + it('converts ternary conditional', () => { + const marcAst: MarcAstNode = { + op: '?:', + args: [ + { op: 'id', args: 'x' }, + { op: 'value', args: 1n }, + { op: 'value', args: 2n } + ] + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.callExpr).toBeDefined(); + expect(expr.callExpr?.function).toBe('_?_:_'); + expect(expr.callExpr?.args).toHaveLength(3); + }); + }); + + describe('Function Call Conversion', () => { + it('converts function call', () => { + const marcAst: MarcAstNode = { + op: 'call', + args: [ + 'size', + [{ op: 'id', args: 'list' }] + ] + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.callExpr).toBeDefined(); + expect(expr.callExpr?.function).toBe('size'); + expect(expr.callExpr?.args).toHaveLength(1); + }); + + it('converts has() macro', () => { + const marcAst: MarcAstNode = { + op: 'call', + args: [ + 'has', + [{ + op: '.', + args: [ + { op: 'id', args: 'request' }, + 'auth' + ] + }] + ], + macro: {} + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.selectExpr).toBeDefined(); + expect(expr.selectExpr?.testOnly).toBe(true); + expect(expr.selectExpr?.field).toBe('auth'); + }); + }); + + describe('Method Call Conversion', () => { + it('converts method call', () => { + const marcAst: MarcAstNode = { + op: 'rcall', + args: [ + 'size', + { op: 'id', args: 'list' }, + [] + ] + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.callExpr).toBeDefined(); + expect(expr.callExpr?.function).toBe('size'); + expect(expr.callExpr?.target).toBeDefined(); + }); + }); + + describe('List Conversion', () => { + it('converts empty list', () => { + const marcAst: MarcAstNode = { + op: 'list', + args: [] + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.listExpr).toBeDefined(); + expect(expr.listExpr?.elements).toHaveLength(0); + }); + + it('converts list with elements', () => { + const marcAst: MarcAstNode = { + op: 'list', + args: [ + { op: 'value', args: 1n }, + { op: 'value', args: 2n }, + { op: 'value', args: 3n } + ] + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.listExpr).toBeDefined(); + expect(expr.listExpr?.elements).toHaveLength(3); + }); + }); + + describe('Map Conversion', () => { + it('converts empty map', () => { + const marcAst: MarcAstNode = { + op: 'map', + args: [] + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.structExpr).toBeDefined(); + expect(expr.structExpr?.entries).toHaveLength(0); + }); + + it('converts map with entries', () => { + const marcAst: MarcAstNode = { + op: 'map', + args: [ + [{ op: 'value', args: 'a' }, { op: 'value', args: 1n }], + [{ op: 'value', args: 'b' }, { op: 'value', args: 2n }] + ] + }; + const expr = convertToProtoExpr(marcAst); + expect(expr.structExpr).toBeDefined(); + expect(expr.structExpr?.entries).toHaveLength(2); + }); + }); +}); + +describe('Converter + Deparser Integration', () => { + it('converts and deparses simple addition', () => { + const marcAst: MarcAstNode = { + op: '+', + args: [ + { op: 'value', args: 1n }, + { op: 'value', args: 2n } + ] + }; + const expr = convertToProtoExpr(marcAst); + const result = deparse(expr); + expect(result).toBe('1 + 2'); + }); + + it('converts and deparses field access', () => { + const marcAst: MarcAstNode = { + op: '.', + args: [ + { op: 'id', args: 'request' }, + 'auth' + ] + }; + const expr = convertToProtoExpr(marcAst); + const result = deparse(expr); + expect(result).toBe('request.auth'); + }); + + it('converts and deparses function call', () => { + const marcAst: MarcAstNode = { + op: 'call', + args: [ + 'size', + [{ op: 'id', args: 'list' }] + ] + }; + const expr = convertToProtoExpr(marcAst); + const result = deparse(expr); + expect(result).toBe('size(list)'); + }); + + it('converts and deparses comparison', () => { + const marcAst: MarcAstNode = { + op: '>', + args: [ + { + op: 'call', + args: [ + 'size', + [{ op: 'id', args: 'list' }] + ] + }, + { op: 'value', args: 0n } + ] + }; + const expr = convertToProtoExpr(marcAst); + const result = deparse(expr); + expect(result).toBe('size(list) > 0'); + }); + + it('converts and deparses logical expression', () => { + const marcAst: MarcAstNode = { + op: '&&', + args: [ + { op: 'id', args: 'a' }, + { op: 'id', args: 'b' } + ] + }; + const expr = convertToProtoExpr(marcAst); + const result = deparse(expr); + expect(result).toBe('a && b'); + }); + + it('converts and deparses ternary', () => { + const marcAst: MarcAstNode = { + op: '?:', + args: [ + { op: 'id', args: 'x' }, + { op: 'value', args: 1n }, + { op: 'value', args: 2n } + ] + }; + const expr = convertToProtoExpr(marcAst); + const result = deparse(expr); + expect(result).toBe('x ? 1 : 2'); + }); + + it('converts and deparses list', () => { + const marcAst: MarcAstNode = { + op: 'list', + args: [ + { op: 'value', args: 1n }, + { op: 'value', args: 2n }, + { op: 'value', args: 3n } + ] + }; + const expr = convertToProtoExpr(marcAst); + const result = deparse(expr); + expect(result).toBe('[1, 2, 3]'); + }); + + it('converts and deparses map', () => { + const marcAst: MarcAstNode = { + op: 'map', + args: [ + [{ op: 'value', args: 'a' }, { op: 'value', args: 1n }] + ] + }; + const expr = convertToProtoExpr(marcAst); + const result = deparse(expr); + expect(result).toBe('{"a": 1}'); + }); + + it('converts and deparses complex expression', () => { + const marcAst: MarcAstNode = { + op: '&&', + args: [ + { + op: '>', + args: [ + { + op: '.', + args: [ + { op: 'id', args: 'user' }, + 'age' + ] + }, + { op: 'value', args: 18n } + ] + }, + { + op: 'in', + args: [ + { op: 'value', args: 'admin' }, + { + op: '.', + args: [ + { op: 'id', args: 'user' }, + 'roles' + ] + } + ] + } + ] + }; + const expr = convertToProtoExpr(marcAst); + const result = deparse(expr); + expect(result).toBe('user.age > 18 && "admin" in user.roles'); + }); +}); diff --git a/packages/cel-proto-parser/package.json b/packages/cel-proto-parser/package.json index 549cb19..93a6390 100644 --- a/packages/cel-proto-parser/package.json +++ b/packages/cel-proto-parser/package.json @@ -42,6 +42,7 @@ "@babel/generator": "^7.26.9", "@babel/types": "^7.26.9", "@launchql/protobufjs": "^7.2.6", + "@marcbachmann/cel-js": "^7.1.0", "nested-obj": "workspace:*", "strfy-js": "workspace:*" }, diff --git a/packages/cel-proto-parser/scripts/test-roundtrip.ts b/packages/cel-proto-parser/scripts/test-roundtrip.ts new file mode 100644 index 0000000..ee55a39 --- /dev/null +++ b/packages/cel-proto-parser/scripts/test-roundtrip.ts @@ -0,0 +1,173 @@ +#!/usr/bin/env npx ts-node --esm +/** + * CEL Round-Trip Test Script + * + * This script tests the CEL -> AST -> CEL pipeline using @marcbachmann/cel-js + * for parsing and our deparser for converting back to CEL strings. + * + * Run with: npx ts-node --esm scripts/test-roundtrip.ts + */ + +import { parse } from '@marcbachmann/cel-js'; +import { deparse, Expr } from '../src/deparser'; +import { convertToProtoExpr, MarcAstNode } from '../src/converter'; + +interface TestResult { + expression: string; + success: boolean; + deparsed?: string; + error?: string; +} + +function parseCel(expression: string): Expr { + const result = parse(expression); + return convertToProtoExpr(result.ast as MarcAstNode); +} + +function testRoundTrip(expression: string): TestResult { + try { + const ast = parseCel(expression); + const deparsed = deparse(ast, { spaces: true }); + + // Try to reparse the deparsed string + const reparsedAst = parseCel(deparsed); + const redeparsed = deparse(reparsedAst, { spaces: true }); + + // Check stability + const isStable = deparsed.trim() === redeparsed.trim(); + + return { + expression, + success: isStable, + deparsed, + error: isStable ? undefined : `Unstable: "${deparsed}" vs "${redeparsed}"` + }; + } catch (error) { + return { + expression, + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } +} + +const testCases = [ + // Literals + '1', + '42', + '3.14', + 'true', + 'false', + 'null', + '"hello"', + '"hello world"', + + // Unsigned integers + '1u', + '42u', + + // Identifiers + 'x', + 'foo', + 'request', + + // Field access + 'request.auth', + 'request.auth.claims', + 'request.auth.claims.email', + + // Arithmetic operators + '1 + 2', + 'a + b', + 'x - y', + 'a * b', + 'x / y', + '1 + 2 * 3', + + // Comparison operators + 'a == b', + 'x != y', + 'a < b', + 'a <= b', + 'a > b', + 'a >= b', + + // Logical operators + 'a && b', + 'x || y', + '!a', + 'a && b && c', + 'a || b || c', + + // Ternary conditional + 'x ? 1 : 2', + 'a == b ? "yes" : "no"', + + // List literals + '[]', + '[1]', + '[1, 2, 3]', + + // Map literals + '{}', + '{"a": 1}', + '{"a": 1, "b": 2}', + + // Index access + 'list[0]', + 'map["key"]', + + // Function calls + 'size(list)', + 'int(x)', + 'string(42)', + + // Method calls + 'list.size()', + 'str.contains("test")', + + // In operator + 'x in list', + '"admin" in roles', + + // Complex expressions + 'request.auth.claims.email == "admin@example.com"', + 'size(request.body) > 0 && request.method == "POST"', + 'user.age >= 18 && "admin" in user.roles' +]; + +console.log('CEL Round-Trip Tests'); +console.log('====================\n'); + +let passed = 0; +let failed = 0; +const failures: TestResult[] = []; + +for (const expression of testCases) { + const result = testRoundTrip(expression); + if (result.success) { + passed++; + console.log(`PASS: ${expression}`); + if (result.deparsed !== expression) { + console.log(` -> ${result.deparsed}`); + } + } else { + failed++; + failures.push(result); + console.log(`FAIL: ${expression}`); + console.log(` Error: ${result.error}`); + } +} + +console.log('\n===================='); +console.log(`Results: ${passed} passed, ${failed} failed`); + +if (failures.length > 0) { + console.log('\nFailures:'); + for (const failure of failures) { + console.log(` - ${failure.expression}: ${failure.error}`); + } + process.exit(1); +} + +process.exit(0); diff --git a/packages/cel-proto-parser/src/converter/index.ts b/packages/cel-proto-parser/src/converter/index.ts new file mode 100644 index 0000000..c9ef9c3 --- /dev/null +++ b/packages/cel-proto-parser/src/converter/index.ts @@ -0,0 +1,273 @@ +/** + * CEL AST Converter + * + * Converts AST from @marcbachmann/cel-js format to CEL proto format + * used by our deparser. + */ + +import type { Expr, Constant, Call, CreateList, CreateStruct, Entry } from '../deparser'; + +/** + * AST node from @marcbachmann/cel-js + */ +interface MarcAstNode { + op: string; + args: unknown; + macro?: Record; +} + +/** + * Convert @marcbachmann/cel-js AST to CEL proto Expr format + */ +export function convertToProtoExpr(node: MarcAstNode, idCounter = { value: 1 }): Expr { + const id = BigInt(idCounter.value++); + + switch (node.op) { + case 'value': + return { + id, + constExpr: convertValue(node.args) + }; + + case 'id': + return { + id, + identExpr: { name: node.args as string } + }; + + case '.': { + const args = node.args as [MarcAstNode, string]; + return { + id, + selectExpr: { + operand: convertToProtoExpr(args[0], idCounter), + field: args[1] + } + }; + } + + case 'call': { + const args = node.args as [string, MarcAstNode[]]; + const funcName = args[0]; + const funcArgs = args[1]; + + // Special handling for has() macro + if (funcName === 'has' && funcArgs.length === 1) { + const selectNode = funcArgs[0]; + if (selectNode.op === '.') { + const selectArgs = selectNode.args as [MarcAstNode, string]; + return { + id, + selectExpr: { + operand: convertToProtoExpr(selectArgs[0], idCounter), + field: selectArgs[1], + testOnly: true + } + }; + } + } + + return { + id, + callExpr: { + function: funcName, + args: funcArgs.map((a) => convertToProtoExpr(a, idCounter)) + } + }; + } + + case 'rcall': { + // Receiver call: target.method(args) + const args = node.args as [string, MarcAstNode, MarcAstNode[]]; + const funcName = args[0]; + const target = args[1]; + const funcArgs = args[2]; + + return { + id, + callExpr: { + target: convertToProtoExpr(target, idCounter), + function: funcName, + args: funcArgs.map((a) => convertToProtoExpr(a, idCounter)) + } + }; + } + + case 'list': { + const elements = node.args as MarcAstNode[]; + return { + id, + listExpr: { + elements: elements.map((e) => convertToProtoExpr(e, idCounter)) + } + }; + } + + case 'map': { + const entries = node.args as [MarcAstNode, MarcAstNode][]; + return { + id, + structExpr: { + entries: entries.map(([key, value]) => ({ + id: BigInt(idCounter.value++), + mapKey: convertToProtoExpr(key, idCounter), + value: convertToProtoExpr(value, idCounter) + })) + } + }; + } + + case 'struct': { + const args = node.args as [string, [string, MarcAstNode][]]; + const messageName = args[0]; + const fields = args[1]; + + return { + id, + structExpr: { + messageName, + entries: fields.map(([fieldKey, value]) => ({ + id: BigInt(idCounter.value++), + fieldKey, + value: convertToProtoExpr(value, idCounter) + })) + } + }; + } + + case '?:': { + // Ternary conditional + const args = node.args as [MarcAstNode, MarcAstNode, MarcAstNode]; + return { + id, + callExpr: { + function: '_?_:_', + args: args.map((a) => convertToProtoExpr(a, idCounter)) + } + }; + } + + case 'index': { + // Index access: obj[key] + const args = node.args as [MarcAstNode, MarcAstNode]; + return { + id, + callExpr: { + function: '_[_]', + args: args.map((a) => convertToProtoExpr(a, idCounter)) + } + }; + } + + // Binary operators + case '+': + case '-': + case '*': + case '/': + case '%': + case '==': + case '!=': + case '<': + case '<=': + case '>': + case '>=': + case '&&': + case '||': + case 'in': { + const args = node.args as [MarcAstNode, MarcAstNode]; + const funcName = getBinaryOperatorName(node.op); + return { + id, + callExpr: { + function: funcName, + args: args.map((a) => convertToProtoExpr(a, idCounter)) + } + }; + } + + // Unary operators + case '!': + case 'neg': { + const args = node.args as [MarcAstNode]; + const funcName = node.op === '!' ? '!_' : '-_'; + return { + id, + callExpr: { + function: funcName, + args: args.map((a) => convertToProtoExpr(a, idCounter)) + } + }; + } + + default: + console.warn(`Unknown operator: ${node.op}`); + return { id }; + } +} + +/** + * Convert a value from @marcbachmann/cel-js to CEL proto Constant + */ +function convertValue(value: unknown): Constant { + if (value === null) { + return { nullValue: null }; + } + + if (typeof value === 'boolean') { + return { boolValue: value }; + } + + if (typeof value === 'bigint') { + return { int64Value: value }; + } + + if (typeof value === 'number') { + if (Number.isInteger(value)) { + return { int64Value: BigInt(value) }; + } + return { doubleValue: value }; + } + + if (typeof value === 'string') { + return { stringValue: value }; + } + + if (value instanceof Uint8Array) { + return { bytesValue: value }; + } + + // Handle unsigned integers (represented as objects in some cases) + if (typeof value === 'object' && value !== null) { + const obj = value as Record; + if ('uint' in obj) { + return { uint64Value: obj.uint as bigint }; + } + } + + return { stringValue: String(value) }; +} + +/** + * Get the CEL proto function name for a binary operator + */ +function getBinaryOperatorName(op: string): string { + const mapping: Record = { + '+': '_+_', + '-': '_-_', + '*': '_*_', + '/': '_/_', + '%': '_%%_', + '==': '_==_', + '!=': '_!=_', + '<': '_<_', + '<=': '_<=_', + '>': '_>_', + '>=': '_>=_', + '&&': '_&&_', + '||': '_||_', + 'in': '_in_' + }; + return mapping[op] || op; +} + +export { MarcAstNode }; diff --git a/packages/cel-proto-parser/src/index.ts b/packages/cel-proto-parser/src/index.ts index 806e13f..20a86f4 100644 --- a/packages/cel-proto-parser/src/index.ts +++ b/packages/cel-proto-parser/src/index.ts @@ -18,5 +18,6 @@ export { Entry, Comprehension } from './deparser'; +export { convertToProtoExpr, MarcAstNode } from './converter'; export * from './ast'; export * from './utils'; diff --git a/packages/cel-proto-parser/test-utils/index.ts b/packages/cel-proto-parser/test-utils/index.ts new file mode 100644 index 0000000..5b9d7e0 --- /dev/null +++ b/packages/cel-proto-parser/test-utils/index.ts @@ -0,0 +1,274 @@ +/** + * CEL Test Utilities + * + * Provides utilities for round-trip testing of CEL expressions: + * CEL string -> parse -> AST -> deparse -> CEL string + * + * Similar to pgsql-parser's FixtureTestUtils and constructive-db's PrettyTest. + */ + +import { parse } from '@marcbachmann/cel-js'; +import { deparse, Expr } from '../src/deparser'; +import { convertToProtoExpr, MarcAstNode } from '../src/converter'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +export interface RoundTripResult { + original: string; + ast: Expr; + deparsed: string; + reparsedAst: Expr | null; + redeparsed: string | null; + success: boolean; + error?: string; +} + +export interface TestUtilOptions { + spaces?: boolean; + normalizeWhitespace?: boolean; +} + +/** + * Normalize a CEL expression for comparison + * - Trims whitespace + * - Normalizes multiple spaces to single space + * - Removes trailing/leading whitespace from each line + */ +export function normalizeCel(cel: string): string { + return cel + .trim() + .split('\n') + .map((line) => line.trim()) + .join('\n') + .replace(/\s+/g, ' '); +} + +/** + * Parse a CEL expression using @marcbachmann/cel-js and convert to proto format + */ +export function parseCel(expression: string): Expr { + const result = parse(expression); + return convertToProtoExpr(result.ast as MarcAstNode); +} + +/** + * Perform a round-trip test on a CEL expression + * + * Pipeline: CEL string -> parse -> AST -> deparse -> CEL string + * + * Optionally performs a second round-trip to verify stability: + * CEL string -> parse -> AST -> deparse -> parse -> AST -> deparse + */ +export function roundTrip( + expression: string, + options: TestUtilOptions = {} +): RoundTripResult { + const { spaces = true, normalizeWhitespace = true } = options; + + try { + // Step 1: Parse original CEL to AST + const ast = parseCel(expression); + + // Step 2: Deparse AST back to CEL string + const deparsed = deparse(ast, { spaces }); + + // Step 3: Try to reparse the deparsed string + let reparsedAst: Expr | null = null; + let redeparsed: string | null = null; + + try { + reparsedAst = parseCel(deparsed); + redeparsed = deparse(reparsedAst, { spaces }); + } catch (reparseError) { + return { + original: expression, + ast, + deparsed, + reparsedAst: null, + redeparsed: null, + success: false, + error: `Reparse failed: ${reparseError instanceof Error ? reparseError.message : String(reparseError)}` + }; + } + + // Step 4: Compare results + const normalizedOriginal = normalizeWhitespace + ? normalizeCel(expression) + : expression; + const normalizedDeparsed = normalizeWhitespace + ? normalizeCel(deparsed) + : deparsed; + const normalizedRedeparsed = normalizeWhitespace + ? normalizeCel(redeparsed) + : redeparsed; + + // Check if deparsed matches redeparsed (stability check) + const isStable = normalizedDeparsed === normalizedRedeparsed; + + return { + original: expression, + ast, + deparsed, + reparsedAst, + redeparsed, + success: isStable, + error: isStable + ? undefined + : `Unstable round-trip: "${normalizedDeparsed}" !== "${normalizedRedeparsed}"` + }; + } catch (error) { + return { + original: expression, + ast: {} as Expr, + deparsed: '', + reparsedAst: null, + redeparsed: null, + success: false, + error: `Parse failed: ${error instanceof Error ? error.message : String(error)}` + }; + } +} + +/** + * Validate that a CEL expression can be round-tripped successfully + * + * Throws an error with detailed information if the round-trip fails. + */ +export function expectRoundTrip( + expression: string, + options: TestUtilOptions = {} +): void { + const result = roundTrip(expression, options); + + if (!result.success) { + const errorMessage = [ + '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + 'CEL Round-Trip Test Failed', + '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', + '', + 'Original CEL:', + '──────────────────────────────────────────────────────────────────────────────', + expression, + '', + 'Deparsed CEL:', + '──────────────────────────────────────────────────────────────────────────────', + result.deparsed, + '', + result.redeparsed !== null + ? [ + 'Re-deparsed CEL:', + '──────────────────────────────────────────────────────────────────────────────', + result.redeparsed, + '' + ].join('\n') + : '', + 'Error:', + '──────────────────────────────────────────────────────────────────────────────', + result.error || 'Unknown error', + '', + '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + ].join('\n'); + + throw new Error(errorMessage); + } +} + +/** + * Load CEL expression fixtures from a file + * + * File format: + * - Each line is a CEL expression + * - Lines starting with # are comments + * - Empty lines are ignored + */ +export function loadFixtures(filePath: string): string[] { + const content = readFileSync(filePath, 'utf-8'); + return content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); +} + +/** + * CEL Test Utility class for fixture-based testing + * + * Similar to constructive-db's PrettyTest class. + * + * Usage: + * ```typescript + * const celTest = new CelTest([ + * '1 + 2', + * 'request.auth.claims.email', + * 'size(list) > 0' + * ]); + * + * celTest.generateTests(); + * ``` + */ +export class CelTest { + private expressions: string[]; + private options: TestUtilOptions; + + constructor(expressions: string[], options: TestUtilOptions = {}) { + this.expressions = expressions; + this.options = options; + } + + /** + * Create a CelTest instance from a fixtures file + */ + static fromFile(filePath: string, options: TestUtilOptions = {}): CelTest { + const expressions = loadFixtures(filePath); + return new CelTest(expressions, options); + } + + /** + * Generate Jest tests for each expression + */ + generateTests(): void { + this.expressions.forEach((expression) => { + it(`round-trip: ${expression}`, () => { + expectRoundTrip(expression, this.options); + }); + }); + } + + /** + * Generate Jest tests with snapshots + */ + generateSnapshotTests(): void { + this.expressions.forEach((expression) => { + it(`snapshot: ${expression}`, () => { + const result = roundTrip(expression, this.options); + expect(result.deparsed).toMatchSnapshot(); + expect(result.success).toBe(true); + }); + }); + } + + /** + * Run all tests and return results + */ + runAll(): RoundTripResult[] { + return this.expressions.map((expr) => roundTrip(expr, this.options)); + } + + /** + * Get summary of test results + */ + getSummary(): { total: number; passed: number; failed: number; failures: RoundTripResult[] } { + const results = this.runAll(); + const passed = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + const failures = results.filter((r) => !r.success); + + return { + total: results.length, + passed, + failed, + failures + }; + } +} + +export { Expr, deparse, parseCel as parse }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fb9eee..aa935f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: '@launchql/protobufjs': specifier: ^7.2.6 version: 7.2.6 + '@marcbachmann/cel-js': + specifier: ^7.1.0 + version: 7.1.0 nested-obj: specifier: workspace:* version: link:../nested-obj/dist @@ -756,6 +759,11 @@ packages: resolution: {integrity: sha512-A8AlzetnS2WIuhijdAzKUyFpR5YbLLfV3luQ4lzBgIBgRfuoBDZeF+RSZPhra+7A6/zTUlrbhKZIOi/MNhqgvQ==} engines: {node: '>=18.0.0'} + '@marcbachmann/cel-js@7.1.0': + resolution: {integrity: sha512-pWAOYqaH41M47OBzt9OPcam7qello+vAdrjt+c2UFEbjVUjQq9rTfCRnEtzIAEScwarkPX90M5qMv0MK2ao5ZQ==} + engines: {node: '>=20.19.0'} + hasBin: true + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -4528,6 +4536,8 @@ snapshots: - supports-color - typescript + '@marcbachmann/cel-js@7.1.0': {} + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.7.1 From 4f3f7be7168a4bc0f9540563f004162dc6faeed2 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 5 Jan 2026 22:58:38 +0000 Subject: [PATCH 3/4] feat(cel-proto-parser): add fixture-based round-trip testing - Add basic.txt fixture file with handful of representative CEL expressions - Update test-roundtrip.ts to load fixtures from file via CLI argument - Run with: npx tsx scripts/test-roundtrip.ts __fixtures__/expressions/basic.txt --- .../__fixtures__/expressions/basic.txt | 34 +++ .../scripts/test-roundtrip.ts | 199 ++++++++++-------- 2 files changed, 149 insertions(+), 84 deletions(-) create mode 100644 packages/cel-proto-parser/__fixtures__/expressions/basic.txt diff --git a/packages/cel-proto-parser/__fixtures__/expressions/basic.txt b/packages/cel-proto-parser/__fixtures__/expressions/basic.txt new file mode 100644 index 0000000..1f8b186 --- /dev/null +++ b/packages/cel-proto-parser/__fixtures__/expressions/basic.txt @@ -0,0 +1,34 @@ +# Basic CEL Expression Fixtures for Round-Trip Testing +# A small handful of representative expressions + +# Simple values +42 +3.14 +true +"hello" + +# Field access +request.auth +request.auth.claims.email + +# Arithmetic +1 + 2 +a * b + c + +# Comparison +x == y +size(list) > 0 + +# Logical +a && b +!enabled + +# Ternary +x ? 1 : 2 + +# Collections +[1, 2, 3] +{"key": "value"} + +# Function calls +size(items) diff --git a/packages/cel-proto-parser/scripts/test-roundtrip.ts b/packages/cel-proto-parser/scripts/test-roundtrip.ts index ee55a39..fa27d6b 100644 --- a/packages/cel-proto-parser/scripts/test-roundtrip.ts +++ b/packages/cel-proto-parser/scripts/test-roundtrip.ts @@ -6,11 +6,18 @@ * for parsing and our deparser for converting back to CEL strings. * * Run with: npx ts-node --esm scripts/test-roundtrip.ts + * Or with a fixture file: npx ts-node --esm scripts/test-roundtrip.ts __fixtures__/expressions/basic.txt */ import { parse } from '@marcbachmann/cel-js'; import { deparse, Expr } from '../src/deparser'; import { convertToProtoExpr, MarcAstNode } from '../src/converter'; +import { readFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); interface TestResult { expression: string; @@ -51,90 +58,114 @@ function testRoundTrip(expression: string): TestResult { } } -const testCases = [ - // Literals - '1', - '42', - '3.14', - 'true', - 'false', - 'null', - '"hello"', - '"hello world"', - - // Unsigned integers - '1u', - '42u', - - // Identifiers - 'x', - 'foo', - 'request', - - // Field access - 'request.auth', - 'request.auth.claims', - 'request.auth.claims.email', - - // Arithmetic operators - '1 + 2', - 'a + b', - 'x - y', - 'a * b', - 'x / y', - '1 + 2 * 3', - - // Comparison operators - 'a == b', - 'x != y', - 'a < b', - 'a <= b', - 'a > b', - 'a >= b', - - // Logical operators - 'a && b', - 'x || y', - '!a', - 'a && b && c', - 'a || b || c', - - // Ternary conditional - 'x ? 1 : 2', - 'a == b ? "yes" : "no"', - - // List literals - '[]', - '[1]', - '[1, 2, 3]', - - // Map literals - '{}', - '{"a": 1}', - '{"a": 1, "b": 2}', - - // Index access - 'list[0]', - 'map["key"]', - - // Function calls - 'size(list)', - 'int(x)', - 'string(42)', - - // Method calls - 'list.size()', - 'str.contains("test")', - - // In operator - 'x in list', - '"admin" in roles', - - // Complex expressions - 'request.auth.claims.email == "admin@example.com"', - 'size(request.body) > 0 && request.method == "POST"', - 'user.age >= 18 && "admin" in user.roles' -]; +/** + * Load CEL expressions from a fixture file + * Lines starting with # are comments, empty lines are ignored + */ +function loadFixtures(filePath: string): string[] { + const content = readFileSync(filePath, 'utf-8'); + return content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); +} + +// Check if a fixture file was provided as argument +const fixtureArg = process.argv[2]; +let testCases: string[]; + +if (fixtureArg) { + // Load from fixture file + const fixturePath = resolve(__dirname, '..', fixtureArg); + console.log(`Loading fixtures from: ${fixturePath}\n`); + testCases = loadFixtures(fixturePath); +} else { + // Use default test cases + testCases = [ + // Literals + '1', + '42', + '3.14', + 'true', + 'false', + 'null', + '"hello"', + '"hello world"', + + // Unsigned integers + '1u', + '42u', + + // Identifiers + 'x', + 'foo', + 'request', + + // Field access + 'request.auth', + 'request.auth.claims', + 'request.auth.claims.email', + + // Arithmetic operators + '1 + 2', + 'a + b', + 'x - y', + 'a * b', + 'x / y', + '1 + 2 * 3', + + // Comparison operators + 'a == b', + 'x != y', + 'a < b', + 'a <= b', + 'a > b', + 'a >= b', + + // Logical operators + 'a && b', + 'x || y', + '!a', + 'a && b && c', + 'a || b || c', + + // Ternary conditional + 'x ? 1 : 2', + 'a == b ? "yes" : "no"', + + // List literals + '[]', + '[1]', + '[1, 2, 3]', + + // Map literals + '{}', + '{"a": 1}', + '{"a": 1, "b": 2}', + + // Index access + 'list[0]', + 'map["key"]', + + // Function calls + 'size(list)', + 'int(x)', + 'string(42)', + + // Method calls + 'list.size()', + 'str.contains("test")', + + // In operator + 'x in list', + '"admin" in roles', + + // Complex expressions + 'request.auth.claims.email == "admin@example.com"', + 'size(request.body) > 0 && request.method == "POST"', + 'user.age >= 18 && "admin" in user.roles' + ]; +} console.log('CEL Round-Trip Tests'); console.log('====================\n'); From 8bc02b625341f7306acfa01c81969f818528e150 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 5 Jan 2026 23:09:23 +0000 Subject: [PATCH 4/4] feat(cel-proto-parser): add policy and RLS template fixtures, fix converter - Add policy.txt with 34 OPA-ish policy expressions (Kubernetes admission control patterns) - Add rls-templates.txt with 17 RLS policy template expressions (direct_owner, membership, etc.) - Fix converter to handle index access operator '[]' (was only handling 'index') - Fix converter to handle unary negation '!_' with single node args (not array) - Note: @marcbachmann/cel-js does not support bitwise AND (&) operator Round-trip test results: - basic.txt: 16/16 pass - policy.txt: 34/34 pass - rls-templates.txt: 17/17 pass --- .../__fixtures__/expressions/policy.txt | 57 +++++++++++++++++++ .../expressions/rls-templates.txt | 46 +++++++++++++++ .../cel-proto-parser/src/converter/index.ts | 16 ++++-- 3 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 packages/cel-proto-parser/__fixtures__/expressions/policy.txt create mode 100644 packages/cel-proto-parser/__fixtures__/expressions/rls-templates.txt diff --git a/packages/cel-proto-parser/__fixtures__/expressions/policy.txt b/packages/cel-proto-parser/__fixtures__/expressions/policy.txt new file mode 100644 index 0000000..16b4672 --- /dev/null +++ b/packages/cel-proto-parser/__fixtures__/expressions/policy.txt @@ -0,0 +1,57 @@ +# Policy-Shaped CEL Expression Fixtures +# OPA-ish patterns useful for Kubernetes admission control +# Rich in syntax variety for deparser testing + +# 1) Core allow/deny shapes +request.user == "system:admin" +request.user in ["alice", "bob", "carol"] +request.verb in ["create", "update"] && request.resource == "pods" +!(request.user.startsWith("system:")) +request.namespace != "kube-system" && request.namespace != "kube-public" + +# 2) Field presence / null-ish handling +has(object.metadata.labels) && has(object.metadata.labels["app"]) +!has(object.spec.replicas) || object.spec.replicas <= 10 +has(object.spec.template) ? object.spec.template.spec.nodeName == "" : true +has(object.spec.securityContext) && has(object.spec.securityContext.runAsNonRoot) && object.spec.securityContext.runAsNonRoot == true +has(params.exemptUsers) && request.user in params.exemptUsers + +# 3) Map + list literals, indexing, membership +object.metadata.labels["team"] in ["infra", "platform"] +object.metadata.annotations["owner"] == request.user +size(object.spec.containers) > 0 +object.spec.containers[0].image.matches("^ghcr\\.io/constructive-io/.+") + +# 4) String ops + regex escaping edge cases +request.user.matches("^user:[a-z0-9_-]{3,32}$") +object.metadata.name.matches("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$") +object.spec.containers.exists(c, c.name.startsWith("sidecar-") && c.image.contains("@sha256:")) +object.spec.serviceAccountName != "" && !object.spec.serviceAccountName.startsWith("default") + +# 5) Quantifiers/macros (great for AST/deparser) +object.spec.containers.all(c, !c.securityContext.privileged) +object.spec.containers.exists(c, has(c.resources.limits["cpu"]) && has(c.resources.limits["memory"])) +object.spec.containers.filter(c, c.image.contains(":latest")).size() == 0 +object.spec.containers.map(c, c.name).exists(n, n == "app") + +# 6) Nested logic + precedence stress tests +request.verb == "update" && (request.user == "alice" || request.user == "bob") && !request.dryRun +(request.user in params.admins) || (request.namespace == "dev" && request.user.matches("^user:dev_.*$")) +!(request.verb == "delete" && request.resource == "namespaces") + +# 7) Numeric + bounds + ternary +has(object.spec.replicas) ? (object.spec.replicas >= 1 && object.spec.replicas <= 20) : true +object.spec.containers.all(c, c.resources.requests["cpu"] <= c.resources.limits["cpu"]) + +# 8) "OPA-ish" patterns (rule-ish in one expression) +request.user in params.admins || (request.verb in ["get","list","watch"] && request.namespace in params.readNamespaces) +request.user == object.metadata.annotations["owner"] || request.user in params.delegates[object.metadata.annotations["owner"]] + +# 9) Equality against structured literals (map/list) +object.metadata.labels == {"app": "api", "tier": "backend"} +object.spec.tolerations.exists(t, t == {"key":"dedicated","operator":"Equal","value":"gpu","effect":"NoSchedule"}) + +# 10) "Weird but valid" deparser edge cases +"" == "" +true ? false ? true : false : true +((a + b) * c) / (d - 1) > 0 diff --git a/packages/cel-proto-parser/__fixtures__/expressions/rls-templates.txt b/packages/cel-proto-parser/__fixtures__/expressions/rls-templates.txt new file mode 100644 index 0000000..bf183b2 --- /dev/null +++ b/packages/cel-proto-parser/__fixtures__/expressions/rls-templates.txt @@ -0,0 +1,46 @@ +# RLS Policy Template CEL Expressions +# Maps to common policy templates: direct_owner, membership, permission bits, etc. +# Uses: row (table row), auth (auth context), acl (ACL table view), params (compile-time params) + +# 1) direct_owner (row.owner_id == user_id) +row.owner_id == auth.user_id +has(row.owner_id) && row.owner_id == auth.user_id + +# 2) multi_owner / any_owner (row.sender_id OR row.receiver_id) +auth.user_id in [row.sender_id, row.receiver_id] + +# 3) membership_by_field (row.org_id exists in ACL for user) +acl.memberships.exists(m, m.user_id == auth.user_id && m.entity_type == "org" && m.entity_id == row.org_id) + +# 4) membership_by_join (row -> join table -> memberships) +acl.joins.exists(j, j.row_id == row.id && acl.memberships.exists(m, m.user_id == auth.user_id && m.entity_type == "group" && m.entity_id == j.entity_id)) + +# 5) scoped_owner / scope_owner (membership exists AND matches scope) +acl.memberships.exists(m, m.user_id == auth.user_id && m.scope == "project" && m.entity_id == row.project_id) +acl.memberships.exists(m, m.user_id == auth.user_id && m.membership_type == params.membership_type && m.entity_id == row.entity_id) + +# 6) hasAccess / permission-bit checks (bitmask) +# NOTE: @marcbachmann/cel-js does not support bitwise AND (&) operator +# These would be: acl.memberships.exists(m, m.user_id == auth.user_id && m.entity_id == row.org_id && (m.perms & params.required_perm) != 0) + +# 7) Role-based overrides (auth.roles) +("admin" in auth.roles) || (row.owner_id == auth.user_id) +auth.user.startsWith("system:") || row.owner_id == auth.user_id + +# 8) acl_exists (no entity match; "member of ANY org") +acl.memberships.exists(m, m.user_id == auth.user_id && m.entity_type == "org") + +# 9) relatedAccess / relationship ownership (row references another object) +acl.parents.exists(p, p.id == row.parent_id && (p.owner_id == auth.user_id || acl.memberships.exists(m, m.user_id == auth.user_id && m.entity_id == p.org_id))) + +# 10) Array membership / group array checks +has(row.allowed_user_ids) && auth.user_id in row.allowed_user_ids +row.group_ids.exists(gid, acl.memberships.exists(m, m.user_id == auth.user_id && m.entity_type == "group" && m.entity_id == gid)) + +# 11) "Owner OR membership" compound policy (very common) +row.owner_id == auth.user_id || acl.memberships.exists(m, m.user_id == auth.user_id && m.entity_id == row.org_id) + +# 12) Insert/update "WITH CHECK" style (new row constraints) +row.owner_id == auth.user_id +newRow.owner_id == auth.user_id +request.verb == "update" ? (newRow.owner_id == oldRow.owner_id) : true diff --git a/packages/cel-proto-parser/src/converter/index.ts b/packages/cel-proto-parser/src/converter/index.ts index c9ef9c3..97d55c9 100644 --- a/packages/cel-proto-parser/src/converter/index.ts +++ b/packages/cel-proto-parser/src/converter/index.ts @@ -147,7 +147,8 @@ export function convertToProtoExpr(node: MarcAstNode, idCounter = { value: 1 }): }; } - case 'index': { + case 'index': + case '[]': { // Index access: obj[key] const args = node.args as [MarcAstNode, MarcAstNode]; return { @@ -187,14 +188,19 @@ export function convertToProtoExpr(node: MarcAstNode, idCounter = { value: 1 }): // Unary operators case '!': - case 'neg': { - const args = node.args as [MarcAstNode]; - const funcName = node.op === '!' ? '!_' : '-_'; + case '!_': + case 'neg': + case '-_': { + // Args can be either a single node or an array with one element + const argNode = Array.isArray(node.args) + ? (node.args as MarcAstNode[])[0] + : (node.args as MarcAstNode); + const funcName = node.op === '!' || node.op === '!_' ? '!_' : '-_'; return { id, callExpr: { function: funcName, - args: args.map((a) => convertToProtoExpr(a, idCounter)) + args: [convertToProtoExpr(argNode, idCounter)] } }; }