# Grammar

> The normative EBNF for both languages, generated from the parsers and pinned by tests.

Source: https://www.sendlang.com/docs/reference/grammar
Section: Reference

Neither grammar below was typed by a human. Both are the output of the parser's
own `GrammarEBNF()` function, dumped into this site at build time and pinned by a
golden test in the parsers' own test suite.

That matters more than it sounds. A hand-maintained grammar in a documentation
site is a grammar that is wrong within two releases, and quietly. These cannot
drift: if the parser changes and the EBNF does not, the language's test suite
fails.

## SendQL

A query is a single boolean expression. Precedence is structural — `or` binds
looser than `and`, which binds looser than `not`, which binds looser than a
primary — so `a or b and c` parses as `a or (b and c)`.

```ebnf
Predicate = Conjunction ("or" Conjunction)* .
Conjunction = Unary ("and" Unary)* .
Unary = ("not" Unary) | Primary .
Primary = ("(" Predicate ")") | Term .
Term = ConsentTerm | MembershipTerm | EventTerm | AgeTerm | AttrTerm .
ConsentTerm = (("subscribed" "to" <string>) | ("opted" "out" "of" <string>) | ("unsubscribed" "from" "all") | "suppressed") .
MembershipTerm = "in" ("list" | "segment") <string> .
EventTerm = CountExpr | ExistsExpr | AggExpr | RecentExpr .
CountExpr = "count" "(" EventSource ")" Comparison .
EventSource = (ActivityRef | <ident>) ("where" EventPred)? Window? .
ActivityRef = "activity" "." <ident> .
EventPred = EventConj ("or" EventConj)* .
EventConj = EventUnary ("and" EventUnary)* .
EventUnary = ("not" EventUnary) | EventPrim .
EventPrim = ("(" EventPred ")") | PropPred .
PropPred = <ident> PropTail .
PropTail = (InList | StrMatch | Comparison) .
InList = "in" "[" Value ("," Value)* "]" .
Value = (TimeExpr | <string> | <number> | ("true" | "false")) .
TimeExpr = ("now" | <date>) ("-" Duration)? .
Duration = <duration> .
StrMatch = ("contains" | ("starts" "with") | ("ends" "with")) <string> .
Comparison = <operator> Value .
Window = (("within" Duration) | TimeBetween) .
TimeBetween = "between" TimeExpr "and" TimeExpr .
ExistsExpr = "exists" "(" EventSource ")" .
AggExpr = ("sum" | "avg" | "min" | "max") "(" <ident> "of" EventSource ")" Comparison .
RecentExpr = ("last" | "first") "(" EventSource ")" Comparison .
AgeTerm = "now" "-" AttrRef (AgeCompare | DurBetween) .
AttrRef = "attr" "." <ident> .
AgeCompare = <operator> Duration .
DurBetween = "between" Duration "and" Duration .
AttrTerm = ("has" AttrRef) | AttrPred .
AttrPred = AttrRef AttrTail .
AttrTail = (("is" ("known" | "unknown")) | InList | StrMatch | Comparison) .
```

## SendFlow

SendFlow is a **strict superset**. Everything from `Predicate` downwards in the
grammar below *is* SendQL's grammar, embedded verbatim — which is what makes the
claim "conditions are SendQL" a structural fact rather than a marketing line.

`WorkflowDuration` and `Duration` are the same `<duration>` token. They remain
separate rules only because they sit in two grammars.

```ebnf
Workflow = "workflow" <string> <ident> "{" Setting* Statement* "}" .
Setting = (Enter | ExitSetting | SendWindow | Reentry | FrequencyCap | EnrollScope) .
Enter = "enter" "on" Trigger ("where" Predicate)? .
Trigger = SegmentTrigger | EventTrigger | ActivityTrigger | DateRelTrigger .
SegmentTrigger = "segment" <string> .
EventTrigger = "event" Ident .
Ident = <ident> .
ActivityTrigger = "activity" "." Ident .
DateRelTrigger = WorkflowDuration ("before" | "after") AttrRef .
WorkflowDuration = <duration> .
AttrRef = "attr" "." <ident> .
Predicate = Conjunction ("or" Conjunction)* .
Conjunction = Unary ("and" Unary)* .
Unary = ("not" Unary) | Primary .
Primary = ("(" Predicate ")") | Term .
Term = ConsentTerm | MembershipTerm | EventTerm | AgeTerm | AttrTerm .
ConsentTerm = (("subscribed" "to" <string>) | ("opted" "out" "of" <string>) | ("unsubscribed" "from" "all") | "suppressed") .
MembershipTerm = "in" ("list" | "segment") <string> .
EventTerm = CountExpr | ExistsExpr | AggExpr | RecentExpr .
CountExpr = "count" "(" EventSource ")" Comparison .
EventSource = (ActivityRef | <ident>) ("where" EventPred)? Window? .
ActivityRef = "activity" "." <ident> .
EventPred = EventConj ("or" EventConj)* .
EventConj = EventUnary ("and" EventUnary)* .
EventUnary = ("not" EventUnary) | EventPrim .
EventPrim = ("(" EventPred ")") | PropPred .
PropPred = <ident> PropTail .
PropTail = (InList | StrMatch | Comparison) .
InList = "in" "[" Value ("," Value)* "]" .
Value = (TimeExpr | <string> | <number> | ("true" | "false")) .
TimeExpr = ("now" | <date>) ("-" Duration)? .
Duration = <duration> .
StrMatch = ("contains" | ("starts" "with") | ("ends" "with")) <string> .
Comparison = <operator> Value .
Window = (("within" Duration) | TimeBetween) .
TimeBetween = "between" TimeExpr "and" TimeExpr .
ExistsExpr = "exists" "(" EventSource ")" .
AggExpr = ("sum" | "avg" | "min" | "max") "(" <ident> "of" EventSource ")" Comparison .
RecentExpr = ("last" | "first") "(" EventSource ")" Comparison .
AgeTerm = "now" "-" AttrRef (AgeCompare | DurBetween) .
AgeCompare = <operator> Duration .
DurBetween = "between" Duration "and" Duration .
AttrTerm = ("has" AttrRef) | AttrPred .
AttrPred = AttrRef AttrTail .
AttrTail = (("is" ("known" | "unknown")) | InList | StrMatch | Comparison) .
ExitSetting = "exit" <string> "when" Predicate .
SendWindow = "send" "window" <clock> "-" <clock> "in" "contact" "timezone" .
Reentry = "reentry" ("once" | ("on" "rematch") | ("per" "occurrence")) .
FrequencyCap = "frequency" "cap" ("off" | (<number> "per" WorkflowDuration)) .
EnrollScope = "enroll" ("forward" | ("existing" ("since" WorkflowDuration)?)) .
Statement = (Send | Wait | If | Split | HoldOut | Repeat | Set | AddToList | "exit") .
Send = "send" <string> (("via" "topic" TopicRef) | "transactional")? .
TopicRef = <string> .
Wait = "wait" (WaitUpTo | ("until" DateExpr) | WorkflowDuration) .
WaitUpTo = "up" "to" WorkflowDuration "until" Predicate TimeoutClause? .
TimeoutClause = "{" "timeout" ":" Statement* "}" .
DateExpr = TimeExpr | AttrRef .
If = "if" Predicate Block ("else" Else)? .
Block = "{" Statement* "}" .
Else = If | Block .
Split = "split" "{" SplitArm+ "}" .
SplitArm = <percent> ":" Block .
HoldOut = "hold" "out" <percent> .
Repeat = "repeat" "up" "to" <number> "every" WorkflowDuration ("until" Predicate)? Block .
Set = "set" AttrRef "=" Value .
AddToList = "add" "to" "list" <string> .
```

## Tokens

Shared by both languages:

| Token | Pattern | Notes |
|---|---|---|
| `Date` | `\d{4}-\d{2}-\d{2}` | `2026-01-01` |
| `Duration` | `\d+(mo\|[smhdwy])` | `30d`, `6mo`. Lexed **before** `Number`. |
| `Number` | `\d+(\.\d+)?` | `42`, `3.5` |
| `String` | `"(\\"\|[^"])*"` | Double quotes; `\"` escapes |
| `Operator` | `>=\|<=\|!=\|=\|>\|<` | Multi-character matched before single |
| `Ident` | `[a-zA-Z_][a-zA-Z0-9_]*` | Keywords are idents matched positionally |
| `Punct` | `[()\[\],.\-]` | |

SendFlow's lexer adds four things and changes nothing else — that "changes
nothing else" is enforced by a test:

| Token | Pattern | Used by |
|---|---|---|
| `Clock` | `\d{1,2}(:\d{2})?(am\|pm)` | `send window 9am-6:30pm` |
| `Percent` | `\d+%` | `split`, `hold out` |
| Braces, colon | `{ } :` | blocks, `timeout:`, split arms |
| Comment | `//` to end of line | everywhere |

Whitespace separates tokens and is otherwise insignificant. The version marker
(`v1`) lexes as an identifier and is validated by the parser.

## Reserved words

See [reserved words](/docs/reference/reserved-words) for the full list of both
sets, and for the rule about which positions they are actually reserved *in*.
