Skip to main content

grammar-sync-update

Use this skill when a grammar sync PR has landed (updating packages/esql/src/parser/antlr/) and a follow-up PR is needed to wire a new ES|QL command or update an existing one in the AST layer. Covers all files that need changes and the exact patterns to follow for both new and existing commands.

Source facts

Repository
elastic/esql-js
Last source activity
October 9, 2026 at 07:38
Detected SKILL.md language
English
Stars
14
Forks
6

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
grammar-sync-update
description
Use this skill when a grammar sync PR has landed (updating packages/esql/src/parser/antlr/) and a follow-up PR is needed to wire a new ES|QL command or update an existing one in the AST layer. Covers all files that need changes and the exact patterns to follow for both new and existing commands.
# Grammar Sync Update ## When to use After the automated grammar sync bot merges a PR, this skill covers two scenarios: **Scenario A — New command** (7 files to change): A brand-new `xxxCommand` rule appeared in the grammar with no handler anywhere in the TypeScript layer. **Scenario B — Existing command update** (1–3 files to change): An existing command's grammar rule gained a new option, new labeled field, or new sub-rule (e.g. `CHANGE_POINT` gained `BY`, `WHERE IN` gained subquery support). **You do NOT need this skill when:** - The grammar sync PR only updated ANTLR-generated files with no new grammar rules (`.ts`, `.interp`, `.tokens` only) — those are handled automatically. **You DO need this skill when:** - A new `xxxCommand` rule appears in `packages/esql/src/parser/antlr/esql_parser.g4` with no corresponding `fromXxxCommand()` in `cst_to_ast_converter.ts`. → **Use Scenario A below.** - The `esql_parser_listener.ts` has new `enterXxxCommand` / `exitXxxCommand` entries with no converter handler. → **Use Scenario A below.** - An existing command's grammar rule changed (new context method, new labeled alternative, new keyword option) and the converter no longer covers it. → **Use Scenario B below.** ## How to detect what changed ```bash # 1. See the full grammar diff git diff HEAD~1 -- packages/esql/src/parser/antlr/esql_parser.g4 # 2. Identify new command rules (Scenario A) git diff HEAD~1 -- packages/esql/src/parser/antlr/esql_parser.g4 | grep "^+.*[Cc]ommand\b" # 3. Cross-check: listener entries without a converter handler (Scenario A) grep "enter.*Command" packages/esql/src/parser/antlr/esql_parser_listener.ts | \ sed 's/.*enter\([A-Za-z]*\)Command.*/\1/' | \ while read cmd; do grep -q "from${cmd}Command" packages/esql/src/parser/core/cst_to_ast_converter.ts || echo "MISSING: $cmd" done # 4. Identify changed rules for existing commands (Scenario B) # Look for new labeled alternatives (_newField), new keyword tokens (BY, WITH, AS), # or new sub-rule methods (ctx.newSubRule()) added to an existing command rule. git diff HEAD~1 -- packages/esql/src/parser/antlr/esql_parser.g4 | grep "^[+-]" | grep -v "^---\|^+++" ``` If step 2/3 shows an entirely new command → **Scenario A**. If step 4 shows additions inside an existing command rule → **Scenario B**. --- ## Files to change (all 7, in order) ### 1. `packages/esql/src/types.ts` **Add a typed interface** if the command has named fields (target field, inference ID, optional config, etc.): ```ts export interface ESQLAstXxxCommand extends ESQLCommand<'xxx'> { targetField: ESQLColumn; // if there's a <qualifiedName> = <expr> pattern expression?: ESQLAstExpression; // the RHS namedParameters?: ESQLMap; // if there's a WITH { map } clause } ``` Add it to the `ESQLAstCommand` union (search for `ESQLAstCommand` type definition): ```ts export type ESQLAstCommand = | ESQLCommand | ... | ESQLAstXxxCommand; // ← add here ``` If the command is trivial (only generic `args`, no named fields), use `ESQLCommand<'xxx'>` directly and skip the interface. --- ### 2. `packages/esql/src/ast/visitor/contexts.ts` Add a visitor context class before the `// Expressions` comment (search for that comment to find the spot): ```ts // XXX <qualifiedName> = <primaryExpression> [WITH <map>] export class XxxCommandVisitorContext< Methods extends VisitorMethods = VisitorMethods, Data extends SharedData = SharedData, > extends CommandVisitorContext<Methods, Data, ESQLAstXxxCommand> {} ``` Also add the import for `ESQLAstXxxCommand` at the top of the file. --- ### 3. `packages/esql/src/ast/visitor/types.ts` Three additions. Search for `agent-marker` comments to find the exact insertion points: **A — `CommandVisitorInput` union** (search for `agent-marker: append new VisitorInput entries here`): ```ts VisitorInput<Methods, 'visitXxxCommand'> & ``` **B — `CommandVisitorOutput` union** (search for `agent-marker: append new VisitorOutput entries here`): ```ts | VisitorOutput<Methods, 'visitXxxCommand'> ``` **C — `VisitorMethods` interface** (search for `visitUserAgentCommand?` — the last method — and append after it): ```ts visitXxxCommand?: Visitor<contexts.XxxCommandVisitorContext<Visitors, Data>, any, any>; ``` --- ### 4. `packages/esql/src/ast/visitor/global_visitor_context.ts` Two additions: **A — dispatcher `switch` case** (search for `visitCommandSpecific` — add a new `case` inside the `switch`): ```ts case 'xxx': { if (!this.methods.visitXxxCommand) break; return this.visitXxxCommand( parent, commandNode as ESQLAstXxxCommand, input as any ); } ``` **B — public visitor method** (search for `visitUserAgentCommand` — the last public visitor method — and append after it): ```ts public visitXxxCommand( parent: contexts.VisitorContext | null, node: ESQLAstXxxCommand, input: types.VisitorInput<Methods, 'visitXxxCommand'> ): types.VisitorOutput<Methods, 'visitXxxCommand'> { const context = new contexts.XxxCommandVisitorContext(this, node, parent); return this.visitWithSpecificContext('visitXxxCommand', context, input); } ``` Add the import for `ESQLAstXxxCommand` at the top of the file. --- ### 5. `packages/esql/src/parser/core/cst_to_ast_converter.ts` Before writing `fromXxxCommand`, grep `cst_to_ast_converter.ts` for an existing private helper. If the grammar shape matches, extend the helper and use a one-liner — don't copy another command's method body. | Grammar shape | Existing helper | |---|---| | `qualifiedName = expr` [+ `commandNamedParameters`] | `fromQualifiedNameAssignmentCommand` (`uri_parts`, `registered_domain`, `user_agent`, `ip_location`) | | `STATS` / `INLINE STATS` | `fromStatsLikeCommand` | | no args | `createCommand` one-liner | Two additions: **A — dispatcher** (search for `agent-marker: append new command dispatcher branches here` and add before it): ```ts const xxxCommandCtx = ctx.xxxCommand(); if (xxxCommandCtx) { return this.fromXxxCommand(xxxCommandCtx); } ``` **B — converter method**: ```ts private fromXxxCommand(ctx: cst.XxxCommandContext): ast.ESQLAstXxxCommand { const command = this.createCommand<'xxx', ast.ESQLAstXxxCommand>('xxx', ctx); // Assignment pattern: targetField = expression if (ctx._targetField && ctx.ASSIGN()) { const targetField = this.toColumn(ctx._targetField); const expression = this.fromPrimaryExpression(ctx.primaryExpression()); const assignment = this.toFunction('=', ctx, undefined, 'binary-expression') as ast.ESQLBinaryExpression; assignment.args.push(targetField, expression); assignment.location = this.extendLocationToArgs(assignment); command.targetField = targetField; command.expression = expression; command.args.push(assignment); } // Optional WITH { map } clause const withOption = this.fromOptionalNamedParametersWithOption(ctx.namedParametersWithOption()); if (withOption) { command.namedParameters = withOption.args[0] as ast.ESQLMap; command.args.push(withOption); } if (ctx.exception) { command.incomplete = true; } return command; } ``` **Patterns by grammar shape** — pick the one that matches: | Grammar shape | Converter call | |---|---| | Single column / qualified name | `this.toColumn(ctx._field)` | | Primary expression | `this.fromPrimaryExpression(ctx.primaryExpression())` | | Constant literal | `this.fromConstantToArray(ctx.constant())` | | String token | `this.fromStringToken(ctx.STRING().symbol)` | | Repeated sub-rule list | `ctx.xxxConfiguration_list()` loop | | Boolean expression | `this.fromBooleanExpression(ctx.booleanExpression())` | | Named option (`KEYWORD field`) | `Builder.option({ name: 'keyword', args: [...] }, { location })` | | Assignment `target = expr` | `this.toFunction('=', ctx, undefined, 'binary-expression')` | | WITH `{ map }` | `this.fromOptionalNamedParametersWithOption(ctx.namedParametersWithOption())` | --- ### 6. `packages/esql/src/pretty_print/constants.ts` The pretty printer is visitor-based and **requires no changes** for most new commands. Only update when: - Command args have **no commas** between them → add to `commandsWithNoCommaArgSeparator` - A command option uses `=` instead of a space before its value → add to `commandOptionsWithEqualsSeparator` ```ts export const commandsWithNoCommaArgSeparator = new Set([ 'dissect', 'sample', 'fork', 'promql', 'xxx', // ← add only if needed ]); ``` --- ### 7. `packages/esql/src/parser/__tests__/xxx.test.ts` (new file) Minimum coverage: 1. Parses basic syntax without errors 2. AST shape matches expected structure (`toMatchObject`) 3. Verifies `incomplete` flag for partial input 4. Round-trips through the pretty-printer ```ts import { parse } from '..'; import { BasicPrettyPrinter } from '../../pretty_print'; describe('XXX command', () => { it('parses basic syntax', () => { const { ast, errors } = parse('FROM a | XXX target = field'); expect(errors).toHaveLength(0); expect(ast[1]).toMatchObject({ type: 'command', name: 'xxx', targetField: { type: 'column', name: 'target' }, }); }); it('round-trips through the printer', () => { const src = 'FROM a | XXX target = field'; const { ast } = parse(src); expect(BasicPrettyPrinter.query(ast)).toBe(src.toUpperCase()); }); }); ``` Reference test files: `packages/esql/src/parser/__tests__/user_agent.test.ts`, `packages/esql/src/parser/__tests__/change_point.test.ts` --- ## Checklist — Scenario A (new command) - [ ] `packages/esql/src/types.ts` — new interface + union updated - [ ] `packages/esql/src/ast/visitor/contexts.ts` — `XxxCommandVisitorContext` added - [ ] `packages/esql/src/ast/visitor/types.ts` — input union, output union, `VisitorMethods` updated - [ ] `packages/esql/src/ast/visitor/global_visitor_context.ts` — switch case + public method added - [ ] `packages/esql/src/parser/core/cst_to_ast_converter.ts` — dispatcher + `fromXxxCommand()` added - [ ] `packages/esql/src/pretty_print/constants.ts` — updated only if needed - [ ] `packages/esql/src/parser/__tests__/xxx.test.ts` — tests added - [ ] `pnpm test` passes - [ ] `pnpm build` passes (no TypeScript errors) - [ ] `pnpm lint` passes --- ## Scenario B — Existing command update The grammar changed an **existing** command's rule: it gained a new option keyword (`BY`, `WITH`, `AS`), a new labeled alternative (`_newField`), or a new sub-rule. The converter method already exists — you only need to extend it. ### Files to change #### `packages/esql/src/parser/core/cst_to_ast_converter.ts` Find the existing `fromXxxCommand()` method and add handling for what's new. **New keyword option** (e.g. `CHANGE_POINT … BY field1, field2`): ```ts if (ctx.BY()) { const args = (ctx._groupings ?? []) .filter((e) => !e.exception) .map((e) => this.fromBooleanExpressionToExpressionOrUnknown(e)); const byOption = this.toByOption(ctx, args); if (byOption) { command.args.push(byOption); command.incomplete ||= byOption.incomplete; } } ``` **New labeled field** (e.g. `_targetField` added to an existing rule): ```ts if (ctx._targetField) { const col = this.toColumn(ctx._targetField); command.targetField = col; // only if types.ts interface has this field command.args.push(col); } ``` **New sub-rule variant** (e.g. `WHERE IN` gained a `LogicalInSubquery` alternative): ```ts // In the existing expression handler, add a branch before the current logic: if (ctx instanceof cst.LogicalInSubqueryContext) { return this.fromLogicalInSubquery(ctx); } // then add the new private method: private fromLogicalInSubquery(ctx: cst.LogicalInSubqueryContext): ast.ESQLAstExpression { const left = this.fromLogicalInLeft(ctx.valueExpression()); const right = this.fromSubquery(ctx.subquery());
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub