Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/workflows/dexpr-testing.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: dexpr

# dexpr is plain C++ with no Kokkos, MPI, netCDF or EKAT dependency, so unlike
# the rest of E3SM it builds and tests on a stock GitHub runner in about a
# minute. That is why this workflow does not need the self-hosted ghci-snl
# machines the eamxx workflows run on, and checks out no submodules.

on:
# Runs on PRs against master, but only if they touch the parser
pull_request:
branches: [ master ]
types: [opened, synchronize, ready_for_review, reopened]
paths:
- 'share/dexpr/**'
- '.github/workflows/dexpr-testing.yml'

# Also guard master itself, so a bad merge is caught immediately
push:
branches: [ master ]
paths:
- 'share/dexpr/**'
- '.github/workflows/dexpr-testing.yml'

# Manual run for debug purposes only
workflow_dispatch:

concurrency:
# Two runs are in the same group if they are testing the same git ref
# - if trigger=pull_request, the ref is refs/pull/<PR_NUMBER>/merge
# - for other triggers, the ref is the branch tested
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: ${{ matrix.compiler }} / ${{ matrix.build_type }}
runs-on: ubuntu-latest

strategy:
# One compiler failing should not hide what the others would have said
fail-fast: false
matrix:
compiler: [gcc, clang]
build_type: [Debug, Release]

steps:
- name: Check out the repository
uses: actions/checkout@v7
with:
persist-credentials: false
show-progress: false

- name: Show action trigger
uses: ./.github/actions/show-workflow-trigger

- name: Select the compiler
run: |
if [ "${{ matrix.compiler }}" = "gcc" ]; then
echo "CC=gcc" >> "$GITHUB_ENV"
echo "CXX=g++" >> "$GITHUB_ENV"
else
echo "CC=clang" >> "$GITHUB_ENV"
echo "CXX=clang++" >> "$GITHUB_ENV"
fi

- name: Configure
run: |
cmake -S share/dexpr -B build \
-DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \
-DDEXPR_WERROR=ON

- name: Build
run: cmake --build build --parallel

- name: Test
run: ctest --test-dir build --output-on-failure --parallel

- name: Smoke test the tool
run: ./build/dexpr functions
4 changes: 4 additions & 0 deletions share/README
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ streams - code for managing "streams" of data files.

test - unit tests for some of the share code

dexpr - lexer and parser for the diagnostics expression language; a
standalone C++20 library with no dependencies, built and tested on
its own rather than as part of csm_share. See dexpr/README.md.


This code was originaly part of CIME in CIME/src/share. Brought in to E3SM
from CIME hash b95a28b417b9b27 from May 4, 2021
2 changes: 2 additions & 0 deletions share/dexpr/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
**/build/**
**/.cache/**
57 changes: 57 additions & 0 deletions share/dexpr/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
cmake_minimum_required(VERSION 3.20)

project(dexpr
VERSION 0.1.0
DESCRIPTION "Lexer and parser for the diagnostics expression language"
LANGUAGES CXX)

# This library is deliberately standalone: it has no Kokkos, MPI, netCDF or
# EKAT dependency, and is not wired into the CIME/csm_share build. That is
# what lets it be configured and tested anywhere cmake and a C++20 compiler
# exist, including a stock GitHub runner.
option(DEXPR_ENABLE_TESTS "Build the unit tests" ON)
option(DEXPR_ENABLE_TOOL "Build the command line tool" ON)

add_library(dexpr
src/ast_print.cpp
src/lexer.cpp
src/parser.cpp
src/precedences.cpp
src/tokens.cpp
)

target_compile_features(dexpr PUBLIC cxx_std_20)

target_include_directories(dexpr
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)

option(DEXPR_WERROR "Treat compiler warnings as errors" OFF)

# Warnings stay on: with no external headers in the include path there is
# nothing here that anyone has to fight, so the library should stay clean.
# CI turns DEXPR_WERROR on to keep it that way. The flags are set here rather
# than per target so tests/ inherits exactly the same set.
set(DEXPR_WARNINGS)
if (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang")
set(DEXPR_WARNINGS -Wall -Wextra -Wpedantic)
if (DEXPR_WERROR)
list(APPEND DEXPR_WARNINGS -Werror)
endif()
endif()

target_compile_options(dexpr PRIVATE ${DEXPR_WARNINGS})

if (DEXPR_ENABLE_TOOL)
# The target is dexpr_cli because the library already owns the name dexpr;
# the binary it produces is still just `dexpr`.
add_executable(dexpr_cli tools/dexpr.cpp)
set_target_properties(dexpr_cli PROPERTIES OUTPUT_NAME dexpr)
target_link_libraries(dexpr_cli PRIVATE dexpr)
endif()

if (DEXPR_ENABLE_TESTS)
include(CTest)
if (BUILD_TESTING)
add_subdirectory(tests)
endif()
endif()
21 changes: 21 additions & 0 deletions share/dexpr/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Peter Schwartz

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
141 changes: 141 additions & 0 deletions share/dexpr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# dexpr

A lexer and parser for the diagnostics expression language: the small DSL used
Comment thread
rljacob marked this conversation as resolved.
to describe derived diagnostics as expressions over model fields, rather than
as hand-written code per diagnostic.

Given a string like

```text
x*y.derivative(dx=dy,['col']).where(x>0)
```

which will be grouped as

```text
(x*y.derivative((dx=dy), ['col']).where((x>0)))
```

`dexpr` produces an abstract syntax tree. It does not evaluate anything, and it
knows nothing about fields, grids or timesteps -- turning an AST into an actual
diagnostic is the caller's job. the Parser::parse function returns a pointer to the
root node of the AST.

Example Usage:

```c++
parser::Parser parser{Lexer{string_input}};
const auto expr = parser.parse(); // expr is a std::unique_ptr
auto string_representation = ast::to_string(*expr); //to_string implmented as a Vistor
```

## Layout

| Path | Contents |
| --- | --- |
| `include/dexpr/` | Public headers |
| `src/` | Lexer, parser, AST printing, token and precedence tables |
| `tests/` | Catch2 unit tests |
| `tools/` | `dexpr`, a small command line helper |

The grammar is handled by a hand-written lexer feeding a Pratt (top-down operator precedence) parser.
AST nodes are represented by a `std::variant`, visited through
`Expression::visit`, which serves as a generic wrapper around `std::visit`.
This design means the nodes should be stable and transformations acting on the AST can be easily added.

The basic set of callable functions lives in one place, `supported_functions.hpp`.
Nothing else in the library is diagnostics-specific.

### Parser terminology

The expression parser uses a hand-written
[Pratt parser](https://tdop.github.io/),
also known as *top-down operator-precedence parsing*. Pratt parsing associates
parsing behavior with tokens and uses operator precedence to determine how an
expression is grouped.

Some terminology used throughout the implementation:

- **Prefix expression** — an expression beginning with an operator
and consumes an expression to its right, e.g. `-x` or `!x`.
Literals and identifers are considered Prefix expressions for parser-function dispatch
but are stored in the AST as a more specific node.

- **Infix expression** — an expression that contains operator located **in**-between a left- and right-hand
expression, e.g. `x + y` or `x < y`.

- **Precedence / binding power** — determines how tightly an operator binds
relative to surrounding operators. For example, multiplication has higher
precedence than addition, so `x + y * z` is parsed as `x + (y * z)`.
Exponentiation is right-associative: `x ** y ** z` is parsed as
`x ** (y ** z)`. This is represented by conditionally modifying its Precedence to
reflect right-binding power.

- **Prefix parse function** — parses an expression beginning with a token that can begin an expression,
such as an identifier, literal, unary operator, or opening parenthesis.

- **Infix parse function** — extends an expression based on the `Precedence` of the following token.
It receives the expression to its left and parses the required expression(s) to its right.
Example tokens include: aritmetic operators, logical operators, opening parenthesis, etc...

## Building

`dexpr` is deliberately standalone. It has no Kokkos, MPI, netCDF or EKAT
dependency, it is not part of the CIME or `csm_share` build, and it requires
only CMake 3.20 and a C++20 compiler:

```shell
./run_tests.sh
```

which is shorthand for

```shell
cmake -S . -B build -DDEXPR_ENABLE_TESTS=ON -DDEXPR_ENABLE_TOOL=ON -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
cmake --build build --parallel
ctest --test-dir build --output-on-failure --parallel
```

Catch2 v3 is used for the tests. An installed copy is used if there is one;
otherwise CMake fetches a pinned version.

### Compiler requirement

C++20, and in practice GCC 11 or newer: numeric literals are parsed and
printed with floating-point `<charconv>`, which is the binding constraint.

### Options

| Option | Default | Effect |
| --- | --- | --- |
| `DEXPR_ENABLE_TESTS` | `ON` | Build the unit tests |
| `DEXPR_ENABLE_TOOL` | `ON` | Build the `dexpr` command line tool |
| `DEXPR_WERROR` | `OFF` | Treat compiler warnings as errors; CI sets this |

## Testing

Every `TEST_CASE` is registered with ctest individually, so a failure names
itself. The same commands run in CI, across gcc and clang in both Debug and
Release, on every pull request touching this directory.

## Planned work

Not implemented here, recorded so it is not rediscovered:

- **Component-supplied functions.** The callable set is fixed in
`supported_functions.hpp` and nothing consults it, so `nope(x)` parses and is
never rejected. The plan is a registry a component fills in at init -- each
function's name, its parameters in positional order, and whether the call is
written free (`where(...)`) or as a method (`T_mid.interp(...)`) -- plus a
pass over the AST that checks calls against it. That pass stays out of the
parser on purpose, so `foo(a, b=c)` parses the same whether or not `foo`
exists.
- **Operator syntax is fixed.** A registry would let a component add functions
but not operators. A new operator means editing the token enum, the lexer,
the precedence table and the parser's dispatch tables.

## Provenance

Vendored from [peterdschwartz/e3sm_diags_parser](https://github.com/peterdschwartz/e3sm_diags_parser)
at `d680d50`, MIT licensed; see `LICENSE`. The upstream name, and its `edp`
abbreviation, were dropped in favour of `dexpr` once the code moved here.
Loading