diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fdc33bf..2b2d3bf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -66,6 +66,7 @@ jobs:
- bash-ast
- docker-parser
- nginx-ast
+ - promql
- yamlize
steps:
diff --git a/packages/promql/README.md b/packages/promql/README.md
new file mode 100644
index 0000000..fcc6092
--- /dev/null
+++ b/packages/promql/README.md
@@ -0,0 +1,80 @@
+# promql
+
+
+
+
+ A TypeScript PromQL parser, AST builders, and deparser
+
+
+A dependency-free toolkit for working with [PromQL](https://prometheus.io/docs/prometheus/latest/querying/basics/) as a typed AST — **parse** query strings into an AST, **build** queries programmatically with typed helpers, and **deparse** an AST back to a canonical PromQL string.
+
+## Install
+
+```sh
+npm install promql
+```
+
+## Why
+
+Composing PromQL with template strings is error-prone (label escaping, precedence, parentheses). This package lets you build queries as typed values and render them safely:
+
+```ts
+import { sum, rate, range, metric, neq, by, deparse } from 'promql';
+
+const query = deparse(
+ sum(
+ rate(range(metric('container_cpu_usage_seconds_total', [neq('container', '')]), '5m')),
+ by('namespace')
+ )
+);
+// => 'sum by (namespace) (rate(container_cpu_usage_seconds_total{container!=""}[5m]))'
+```
+
+## Parse
+
+```ts
+import { parse } from 'promql';
+
+const ast = parse('sum by (namespace) (rate(x[5m]))');
+// { type: 'AggregateExpr', op: 'sum', grouping: { modifier: 'by', labels: ['namespace'] }, ... }
+```
+
+## Deparse
+
+```ts
+import { parse, deparse } from 'promql';
+
+deparse(parse('a+b*c')); // 'a + b * c'
+```
+
+The deparser inserts parentheses based on operator precedence, so ASTs built by hand (without explicit `ParenExpr` nodes) always render to a string that reparses to the same tree.
+
+## Builders
+
+| Helper | PromQL |
+|---|---|
+| `metric('up', { job: 'api' })` | `up{job="api"}` |
+| `selector({ __name__: 'up' })` | `{__name__="up"}` |
+| `eq / neq / re / nre` | `=` `!=` `=~` `!~` matchers |
+| `range(sel, '5m')` | `sel[5m]` |
+| `subquery(expr, '1h', '1m')` | `expr[1h:1m]` |
+| `rate / irate / increase / *_over_time` | function calls |
+| `sum / min / max / avg / count / ...` | aggregations |
+| `topk / bottomk / quantile / countValues` | aggregations with a parameter |
+| `by(...) / without(...)` | grouping clauses |
+| `add / sub / mul / div / pow / and / or / unless` | binary operators |
+| `on(...) / ignoring(...)` | vector matching |
+| `offset(expr, '5m') / at(expr, 100 \| 'start' \| 'end')` | modifiers |
+
+## API
+
+- `parse(input: string): Expr` / `class Parser`
+- `deparse(node: Expr): string` / `class Deparser`
+- `tokenize(input: string): Token[]` / `class Lexer`, `TokenType`
+- `cleanTree`, `astEqual`, `printAst`
+- All AST node types (`Expr`, `VectorSelector`, `MatrixSelector`, `AggregateExpr`, …)
+- All builder helpers
+
+## License
+
+MIT
diff --git a/packages/promql/__tests__/builders.test.ts b/packages/promql/__tests__/builders.test.ts
new file mode 100644
index 0000000..b363466
--- /dev/null
+++ b/packages/promql/__tests__/builders.test.ts
@@ -0,0 +1,49 @@
+import * as b from '../src/builders';
+import { deparse } from '../src/deparser';
+import { parse } from '../src/parser';
+import { cleanTree } from '../src/clean';
+
+describe('promql builders', () => {
+ it('builds the namespace CPU query used by the collector', () => {
+ const expr = b.sum(
+ b.rate(b.range(b.metric('container_cpu_usage_seconds_total', [b.neq('container', '')]), '5m')),
+ b.by('namespace')
+ );
+ expect(deparse(expr)).toBe(
+ 'sum by (namespace) (rate(container_cpu_usage_seconds_total{container!=""}[5m]))'
+ );
+ });
+
+ it('builds the namespace memory query used by the collector', () => {
+ const expr = b.sum(
+ b.metric('container_memory_working_set_bytes', [
+ b.eq('namespace', 'tenant-a'),
+ b.neq('container', ''),
+ b.neq('image', ''),
+ ])
+ );
+ expect(deparse(expr)).toBe(
+ 'sum(container_memory_working_set_bytes{namespace="tenant-a", container!="", image!=""})'
+ );
+ });
+
+ it('builder output reparses to an equal AST', () => {
+ const expr = b.topk(5, b.rate(b.range(b.metric('x', { job: 'api' }), '1m')), b.by('pod'));
+ const reparsed = parse(deparse(expr));
+ expect(cleanTree(reparsed)).toEqual(cleanTree(expr));
+ });
+
+ it('escapes label values via JSON quoting', () => {
+ const expr = b.metric('m', [b.eq('path', 'a"b\\c')]);
+ expect(deparse(expr)).toBe('m{path="a\\"b\\\\c"}');
+ // and it reparses back to the original value
+ const reparsed = parse(deparse(expr)) as { matchers: { value: string }[] };
+ expect(reparsed.matchers[0].value).toBe('a"b\\c');
+ });
+
+ it('at() accepts start/end/number', () => {
+ expect(deparse(b.at(b.metric('x'), 'start'))).toBe('x @ start()');
+ expect(deparse(b.at(b.metric('x'), 'end'))).toBe('x @ end()');
+ expect(deparse(b.at(b.metric('x'), 1609746000))).toBe('x @ 1609746000');
+ });
+});
diff --git a/packages/promql/__tests__/deparser.test.ts b/packages/promql/__tests__/deparser.test.ts
new file mode 100644
index 0000000..027681f
--- /dev/null
+++ b/packages/promql/__tests__/deparser.test.ts
@@ -0,0 +1,56 @@
+import * as b from '../src/builders';
+import { deparse } from '../src/deparser';
+
+describe('promql deparser', () => {
+ it('deparses a selector with matchers', () => {
+ expect(deparse(b.metric('http_requests_total', { job: 'api' }))).toBe(
+ 'http_requests_total{job="api"}'
+ );
+ });
+
+ it('deparses a nameless selector', () => {
+ expect(deparse(b.selector({ __name__: 'up' }))).toBe('{__name__="up"}');
+ });
+
+ it('deparses rate over a range', () => {
+ expect(deparse(b.rate(b.range(b.metric('x'), '5m')))).toBe('rate(x[5m])');
+ });
+
+ it('deparses aggregation with by-grouping', () => {
+ expect(deparse(b.sum(b.rate(b.range(b.metric('x'), '5m')), b.by('namespace')))).toBe(
+ 'sum by (namespace) (rate(x[5m]))'
+ );
+ });
+
+ it('deparses topk with parameter', () => {
+ expect(deparse(b.topk(3, b.metric('x')))).toBe('topk(3, x)');
+ });
+
+ it('adds parentheses to preserve precedence for built ASTs', () => {
+ // (a + b) * c — built without explicit ParenExpr
+ const expr = b.mul(b.add(b.metric('a'), b.metric('b')), b.metric('c'));
+ expect(deparse(expr)).toBe('(a + b) * c');
+ });
+
+ it('does not add unnecessary parentheses', () => {
+ const expr = b.add(b.metric('a'), b.mul(b.metric('b'), b.metric('c')));
+ expect(deparse(expr)).toBe('a + b * c');
+ });
+
+ it('deparses offset and @ modifiers', () => {
+ expect(deparse(b.offset(b.at(b.metric('x'), 100), '5m'))).toBe('x @ 100 offset 5m');
+ });
+
+ it('deparses bool + vector matching', () => {
+ const expr = b.binary('==', b.metric('a'), b.metric('b'), {
+ bool: true,
+ matching: { on: ['x'], groupLeft: ['y'] },
+ });
+ expect(deparse(expr)).toBe('a == bool on (x) group_left (y) b');
+ });
+
+ it('deparses Inf/NaN', () => {
+ expect(deparse(b.num(Infinity))).toBe('+Inf');
+ expect(deparse(b.num(NaN))).toBe('NaN');
+ });
+});
diff --git a/packages/promql/__tests__/parser.test.ts b/packages/promql/__tests__/parser.test.ts
new file mode 100644
index 0000000..d7d4c9a
--- /dev/null
+++ b/packages/promql/__tests__/parser.test.ts
@@ -0,0 +1,123 @@
+import { cleanTree } from '../src/clean';
+import { parse } from '../src/parser';
+
+describe('promql parser', () => {
+ it('parses a bare metric name', () => {
+ expect(cleanTree(parse('up'))).toEqual({
+ type: 'VectorSelector',
+ name: 'up',
+ matchers: [],
+ });
+ });
+
+ it('parses a selector with label matchers', () => {
+ expect(cleanTree(parse('http_requests_total{job="api", code!~"5.."}'))).toEqual({
+ type: 'VectorSelector',
+ name: 'http_requests_total',
+ matchers: [
+ { name: 'job', op: '=', value: 'api' },
+ { name: 'code', op: '!~', value: '5..' },
+ ],
+ });
+ });
+
+ it('parses a matrix selector with offset and @', () => {
+ expect(cleanTree(parse('x[5m] @ 100 offset -1m'))).toEqual({
+ type: 'MatrixSelector',
+ vectorSelector: { type: 'VectorSelector', name: 'x', matchers: [] },
+ range: '5m',
+ at: { kind: 'timestamp', value: 100 },
+ offset: '-1m',
+ });
+ });
+
+ it('parses rate(range) function calls', () => {
+ expect(cleanTree(parse('rate(x[5m])'))).toEqual({
+ type: 'Call',
+ func: 'rate',
+ args: [
+ {
+ type: 'MatrixSelector',
+ vectorSelector: { type: 'VectorSelector', name: 'x', matchers: [] },
+ range: '5m',
+ },
+ ],
+ });
+ });
+
+ it('parses aggregation with by-clause before args', () => {
+ const ast = cleanTree(parse('sum by (namespace) (rate(x[5m]))')) as {
+ type: string;
+ op: string;
+ grouping: unknown;
+ };
+ expect(ast.type).toBe('AggregateExpr');
+ expect(ast.op).toBe('sum');
+ expect(ast.grouping).toEqual({ modifier: 'by', labels: ['namespace'] });
+ });
+
+ it('parses aggregation with by-clause after args', () => {
+ const ast = cleanTree(parse('sum(rate(x[5m])) by (namespace)')) as { grouping: unknown };
+ expect(ast.grouping).toEqual({ modifier: 'by', labels: ['namespace'] });
+ });
+
+ it('parses topk with a parameter', () => {
+ const ast = cleanTree(parse('topk(3, x)')) as { op: string; param: unknown };
+ expect(ast.op).toBe('topk');
+ expect(ast.param).toEqual({ type: 'NumberLiteral', value: 3 });
+ });
+
+ it('respects operator precedence', () => {
+ const ast = cleanTree(parse('a + b * c')) as { op: string; rhs: { op: string } };
+ expect(ast.op).toBe('+');
+ expect(ast.rhs.op).toBe('*');
+ });
+
+ it('parses ^ as right-associative', () => {
+ const ast = cleanTree(parse('a ^ b ^ c')) as { op: string; rhs: { op: string } };
+ expect(ast.op).toBe('^');
+ expect(ast.rhs.op).toBe('^');
+ });
+
+ it('parses binary comparison with bool and on() matching', () => {
+ const ast = cleanTree(parse('a == bool on (x) b')) as {
+ op: string;
+ bool: boolean;
+ matching: unknown;
+ };
+ expect(ast.op).toBe('==');
+ expect(ast.bool).toBe(true);
+ expect(ast.matching).toEqual({ on: ['x'] });
+ });
+
+ it('parses group_left matching', () => {
+ const ast = cleanTree(parse('a / on (x) group_left (y) b')) as { matching: unknown };
+ expect(ast.matching).toEqual({ on: ['x'], groupLeft: ['y'] });
+ });
+
+ it('parses subquery with step', () => {
+ expect(cleanTree(parse('rate(x[5m])[1h:1m]'))).toMatchObject({
+ type: 'SubqueryExpr',
+ range: '1h',
+ step: '1m',
+ });
+ });
+
+ it('parses Inf and NaN literals', () => {
+ expect(cleanTree(parse('Inf'))).toEqual({ type: 'NumberLiteral', value: Infinity });
+ expect((cleanTree(parse('NaN')) as { value: number }).value).toBeNaN();
+ });
+
+ it('parses recording-rule metric names with colons', () => {
+ expect(cleanTree(parse('job:http_requests:rate5m'))).toEqual({
+ type: 'VectorSelector',
+ name: 'job:http_requests:rate5m',
+ matchers: [],
+ });
+ });
+
+ it('throws on malformed input', () => {
+ expect(() => parse('sum(')).toThrow();
+ expect(() => parse('{')).toThrow();
+ });
+});
diff --git a/packages/promql/__tests__/roundtrip.test.ts b/packages/promql/__tests__/roundtrip.test.ts
new file mode 100644
index 0000000..f2e4211
--- /dev/null
+++ b/packages/promql/__tests__/roundtrip.test.ts
@@ -0,0 +1,31 @@
+import { roundtrip } from '../test-utils';
+
+describe('promql roundtrip (parse → deparse → parse)', () => {
+ const cases = [
+ 'up',
+ 'http_requests_total{job="api", code!~"5.."}',
+ 'rate(x[5m])',
+ 'sum by (namespace) (rate(container_cpu_usage_seconds_total{container!=""}[5m]))',
+ 'sum(container_memory_working_set_bytes{namespace="tenant-a", container!="", image!=""})',
+ 'topk(3, avg_over_time(x[1h]))',
+ 'a + b * c',
+ '(a + b) * c',
+ 'a ^ b ^ c',
+ 'a == bool on (x) group_left (y) b',
+ 'histogram_quantile(0.9, rate(http_request_duration_seconds_bucket[5m]))',
+ 'rate(x[5m])[1h:1m]',
+ 'x @ 100 offset -1m',
+ 'quantile(0.95, node_cpu) without (cpu)',
+ '-foo + bar',
+ 'foo and bar unless baz',
+ 'foo or bar or baz',
+ 'job:http_requests:rate5m',
+ ];
+
+ for (const input of cases) {
+ it(`roundtrips: ${input}`, () => {
+ const { first, second } = roundtrip(input);
+ expect(first).toEqual(second);
+ });
+ }
+});
diff --git a/packages/promql/jest.config.js b/packages/promql/jest.config.js
new file mode 100644
index 0000000..f4c0ce0
--- /dev/null
+++ b/packages/promql/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/promql/package.json b/packages/promql/package.json
new file mode 100644
index 0000000..663ebde
--- /dev/null
+++ b/packages/promql/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "promql",
+ "version": "0.1.0",
+ "author": "Constructive ",
+ "description": "TypeScript PromQL parser, AST builders, and deparser",
+ "main": "index.js",
+ "module": "esm/index.js",
+ "types": "index.d.ts",
+ "homepage": "https://github.com/constructive-io/dev-utils",
+ "license": "MIT",
+ "publishConfig": {
+ "access": "public",
+ "directory": "dist"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/constructive-io/dev-utils"
+ },
+ "bugs": {
+ "url": "https://github.com/constructive-io/dev-utils/issues"
+ },
+ "scripts": {
+ "copy": "makage assets",
+ "clean": "makage clean",
+ "prepublishOnly": "npm run build",
+ "build": "makage build",
+ "lint": "eslint . --fix",
+ "test": "jest",
+ "test:watch": "jest --watch"
+ },
+ "keywords": [
+ "promql",
+ "prometheus",
+ "parser",
+ "deparser",
+ "ast",
+ "typescript"
+ ],
+ "devDependencies": {
+ "makage": "0.1.10"
+ }
+}
diff --git a/packages/promql/src/builders.ts b/packages/promql/src/builders.ts
new file mode 100644
index 0000000..f4593cb
--- /dev/null
+++ b/packages/promql/src/builders.ts
@@ -0,0 +1,192 @@
+import type {
+ AggregateExpr,
+ AggregateOp,
+ AtModifier,
+ BinaryExpr,
+ BinaryOp,
+ Call,
+ Expr,
+ LabelMatcher,
+ MatchOp,
+ MatrixSelector,
+ NumberLiteral,
+ ParenExpr,
+ StringLiteral,
+ SubqueryExpr,
+ UnaryExpr,
+ VectorMatching,
+ VectorSelector,
+} from './types';
+
+export type Grouping = { modifier: 'by' | 'without'; labels: string[] };
+export type MatchersInput = LabelMatcher[] | Record;
+
+function toMatchers(input?: MatchersInput): LabelMatcher[] {
+ if (!input) return [];
+ if (Array.isArray(input)) return input;
+ return Object.entries(input).map(([name, value]) => ({ name, op: '=' as MatchOp, value }));
+}
+
+/** Number literal. */
+export function num(value: number): NumberLiteral {
+ return { type: 'NumberLiteral', value };
+}
+
+/** String literal. */
+export function str(value: string): StringLiteral {
+ return { type: 'StringLiteral', value };
+}
+
+/** A label matcher, e.g. `eq('job', 'api')` → `job="api"`. */
+export function eq(name: string, value: string): LabelMatcher {
+ return { name, op: '=', value };
+}
+export function neq(name: string, value: string): LabelMatcher {
+ return { name, op: '!=', value };
+}
+export function re(name: string, value: string): LabelMatcher {
+ return { name, op: '=~', value };
+}
+export function nre(name: string, value: string): LabelMatcher {
+ return { name, op: '!~', value };
+}
+
+/** Instant vector selector with a metric name, e.g. `metric('up', { job: 'api' })`. */
+export function metric(name: string, matchers?: MatchersInput): VectorSelector {
+ return { type: 'VectorSelector', name, matchers: toMatchers(matchers) };
+}
+
+/** Instant vector selector without a metric name, e.g. `selector({ __name__: 'up' })`. */
+export function selector(matchers: MatchersInput): VectorSelector {
+ return { type: 'VectorSelector', matchers: toMatchers(matchers) };
+}
+
+/** Range vector, e.g. `range(metric('x'), '5m')` → `x[5m]`. */
+export function range(vectorSelector: VectorSelector, window: string): MatrixSelector {
+ return { type: 'MatrixSelector', vectorSelector, range: window };
+}
+
+/** Subquery, e.g. `subquery(rate(range(metric('x'), '5m')), '1h', '1m')`. */
+export function subquery(expr: Expr, window: string, step?: string): SubqueryExpr {
+ return { type: 'SubqueryExpr', expr, range: window, ...(step ? { step } : {}) };
+}
+
+/** Parenthesized expression. */
+export function paren(expr: Expr): ParenExpr {
+ return { type: 'ParenExpr', expr };
+}
+
+/** Unary negation / plus. */
+export function neg(expr: Expr): UnaryExpr {
+ return { type: 'UnaryExpr', op: '-', expr };
+}
+export function pos(expr: Expr): UnaryExpr {
+ return { type: 'UnaryExpr', op: '+', expr };
+}
+
+/** Attach an `offset` modifier (accepts a signed duration string, e.g. `-5m`). */
+export function offset(
+ expr: T,
+ duration: string
+): T {
+ return { ...expr, offset: duration };
+}
+
+/** Attach an `@` modifier. */
+export function at(
+ expr: T,
+ modifier: AtModifier | number | 'start' | 'end'
+): T {
+ let mod: AtModifier;
+ if (modifier === 'start') mod = { kind: 'start' };
+ else if (modifier === 'end') mod = { kind: 'end' };
+ else if (typeof modifier === 'number') mod = { kind: 'timestamp', value: modifier };
+ else mod = modifier;
+ return { ...expr, at: mod };
+}
+
+/** Generic function call, e.g. `call('rate', range(metric('x'), '5m'))`. */
+export function call(func: string, ...args: Expr[]): Call {
+ return { type: 'Call', func, args };
+}
+
+// Common function helpers.
+export const rate = (v: MatrixSelector): Call => call('rate', v);
+export const irate = (v: MatrixSelector): Call => call('irate', v);
+export const increase = (v: MatrixSelector): Call => call('increase', v);
+export const avgOverTime = (v: MatrixSelector): Call => call('avg_over_time', v);
+export const maxOverTime = (v: MatrixSelector): Call => call('max_over_time', v);
+export const minOverTime = (v: MatrixSelector): Call => call('min_over_time', v);
+export const sumOverTime = (v: MatrixSelector): Call => call('sum_over_time', v);
+export const histogramQuantile = (q: number | Expr, v: Expr): Call =>
+ call('histogram_quantile', typeof q === 'number' ? num(q) : q, v);
+
+/** `by (...labels)` grouping clause. */
+export function by(...labels: string[]): Grouping {
+ return { modifier: 'by', labels };
+}
+/** `without (...labels)` grouping clause. */
+export function without(...labels: string[]): Grouping {
+ return { modifier: 'without', labels };
+}
+
+function agg(op: AggregateOp, expr: Expr, grouping?: Grouping): AggregateExpr {
+ return { type: 'AggregateExpr', op, expr, ...(grouping ? { grouping } : {}) };
+}
+function aggParam(op: AggregateOp, param: Expr, expr: Expr, grouping?: Grouping): AggregateExpr {
+ return { type: 'AggregateExpr', op, expr, param, ...(grouping ? { grouping } : {}) };
+}
+
+export const sum = (expr: Expr, grouping?: Grouping): AggregateExpr => agg('sum', expr, grouping);
+export const min = (expr: Expr, grouping?: Grouping): AggregateExpr => agg('min', expr, grouping);
+export const max = (expr: Expr, grouping?: Grouping): AggregateExpr => agg('max', expr, grouping);
+export const avg = (expr: Expr, grouping?: Grouping): AggregateExpr => agg('avg', expr, grouping);
+export const group = (expr: Expr, grouping?: Grouping): AggregateExpr => agg('group', expr, grouping);
+export const count = (expr: Expr, grouping?: Grouping): AggregateExpr => agg('count', expr, grouping);
+export const stddev = (expr: Expr, grouping?: Grouping): AggregateExpr => agg('stddev', expr, grouping);
+export const stdvar = (expr: Expr, grouping?: Grouping): AggregateExpr => agg('stdvar', expr, grouping);
+
+export const topk = (k: number | Expr, expr: Expr, grouping?: Grouping): AggregateExpr =>
+ aggParam('topk', typeof k === 'number' ? num(k) : k, expr, grouping);
+export const bottomk = (k: number | Expr, expr: Expr, grouping?: Grouping): AggregateExpr =>
+ aggParam('bottomk', typeof k === 'number' ? num(k) : k, expr, grouping);
+export const quantile = (q: number | Expr, expr: Expr, grouping?: Grouping): AggregateExpr =>
+ aggParam('quantile', typeof q === 'number' ? num(q) : q, expr, grouping);
+export const countValues = (label: string, expr: Expr, grouping?: Grouping): AggregateExpr =>
+ aggParam('count_values', str(label), expr, grouping);
+
+export interface BinaryOptions {
+ bool?: boolean;
+ matching?: VectorMatching;
+}
+
+/** Generic binary expression. */
+export function binary(op: BinaryOp, lhs: Expr, rhs: Expr, options: BinaryOptions = {}): BinaryExpr {
+ return {
+ type: 'BinaryExpr',
+ op,
+ lhs,
+ rhs,
+ ...(options.bool ? { bool: options.bool } : {}),
+ ...(options.matching ? { matching: options.matching } : {}),
+ };
+}
+
+export const add = (l: Expr, r: Expr, o?: BinaryOptions): BinaryExpr => binary('+', l, r, o);
+export const sub = (l: Expr, r: Expr, o?: BinaryOptions): BinaryExpr => binary('-', l, r, o);
+export const mul = (l: Expr, r: Expr, o?: BinaryOptions): BinaryExpr => binary('*', l, r, o);
+export const div = (l: Expr, r: Expr, o?: BinaryOptions): BinaryExpr => binary('/', l, r, o);
+export const mod = (l: Expr, r: Expr, o?: BinaryOptions): BinaryExpr => binary('%', l, r, o);
+export const pow = (l: Expr, r: Expr, o?: BinaryOptions): BinaryExpr => binary('^', l, r, o);
+export const and = (l: Expr, r: Expr, o?: BinaryOptions): BinaryExpr => binary('and', l, r, o);
+export const or = (l: Expr, r: Expr, o?: BinaryOptions): BinaryExpr => binary('or', l, r, o);
+export const unless = (l: Expr, r: Expr, o?: BinaryOptions): BinaryExpr => binary('unless', l, r, o);
+
+/** `on (...labels)` vector matching clause. */
+export function on(...labels: string[]): VectorMatching {
+ return { on: labels };
+}
+/** `ignoring (...labels)` vector matching clause. */
+export function ignoring(...labels: string[]): VectorMatching {
+ return { ignoring: labels };
+}
diff --git a/packages/promql/src/clean.ts b/packages/promql/src/clean.ts
new file mode 100644
index 0000000..10267c6
--- /dev/null
+++ b/packages/promql/src/clean.ts
@@ -0,0 +1,31 @@
+import type { AstNode } from './types';
+
+/**
+ * Remove range/location information from an AST for structural comparison.
+ */
+export function cleanTree(node: T): T {
+ if (Array.isArray(node)) {
+ return node.map((item) => cleanTree(item)) as unknown as T;
+ }
+
+ if (node === null || typeof node !== 'object') {
+ return node;
+ }
+
+ const cleaned: Record = {};
+ for (const [key, value] of Object.entries(node as Record)) {
+ if (key === 'loc' || key === 'location') continue;
+ cleaned[key] = cleanTree(value);
+ }
+ return cleaned as unknown as T;
+}
+
+/** Deep structural equality for AST nodes (ignoring ranges). */
+export function astEqual(a: AstNode, b: AstNode): boolean {
+ return JSON.stringify(cleanTree(a)) === JSON.stringify(cleanTree(b));
+}
+
+/** Pretty-print an AST node as indented JSON (for debugging). */
+export function printAst(node: AstNode): string {
+ return JSON.stringify(cleanTree(node), null, 2);
+}
diff --git a/packages/promql/src/deparser.ts b/packages/promql/src/deparser.ts
new file mode 100644
index 0000000..99cf5d2
--- /dev/null
+++ b/packages/promql/src/deparser.ts
@@ -0,0 +1,213 @@
+import type {
+ AggregateExpr,
+ AtModifier,
+ BinaryExpr,
+ BinaryOp,
+ Call,
+ Expr,
+ LabelMatcher,
+ MatrixSelector,
+ NumberLiteral,
+ ParenExpr,
+ StringLiteral,
+ SubqueryExpr,
+ UnaryExpr,
+ VectorMatching,
+ VectorSelector,
+} from './types';
+
+const UNARY_PREC = 7;
+const ATOM_PREC = 100;
+
+function binOpPrec(op: BinaryOp): number {
+ switch (op) {
+ case 'or':
+ return 1;
+ case 'and':
+ case 'unless':
+ return 2;
+ case '==':
+ case '!=':
+ case '<=':
+ case '<':
+ case '>=':
+ case '>':
+ return 3;
+ case '+':
+ case '-':
+ return 4;
+ case '*':
+ case '/':
+ case '%':
+ case 'atan2':
+ return 5;
+ case '^':
+ return 6;
+ }
+}
+
+function isRightAssoc(op: BinaryOp): boolean {
+ return op === '^';
+}
+
+function nodePrec(e: Expr): number {
+ if (e.type === 'BinaryExpr') return binOpPrec(e.op);
+ if (e.type === 'UnaryExpr') return UNARY_PREC;
+ return ATOM_PREC;
+}
+
+/**
+ * Deparser: renders a PromQL AST back to a canonical PromQL string.
+ *
+ * Parentheses are inserted based on operator precedence so that ASTs built
+ * programmatically (without explicit `ParenExpr` nodes) still deparse to a
+ * string that reparses to the same tree.
+ */
+export class Deparser {
+ deparse(node: Expr): string {
+ switch (node.type) {
+ case 'NumberLiteral':
+ return this.number(node);
+ case 'StringLiteral':
+ return this.string(node);
+ case 'VectorSelector':
+ return this.vectorSelector(node);
+ case 'MatrixSelector':
+ return this.matrixSelector(node);
+ case 'SubqueryExpr':
+ return this.subquery(node);
+ case 'ParenExpr':
+ return this.paren(node);
+ case 'UnaryExpr':
+ return this.unary(node);
+ case 'BinaryExpr':
+ return this.binary(node);
+ case 'AggregateExpr':
+ return this.aggregate(node);
+ case 'Call':
+ return this.call(node);
+ }
+ }
+
+ private number(n: NumberLiteral): string {
+ if (Number.isNaN(n.value)) return 'NaN';
+ if (n.value === Infinity) return '+Inf';
+ if (n.value === -Infinity) return '-Inf';
+ return String(n.value);
+ }
+
+ private string(n: StringLiteral): string {
+ return JSON.stringify(n.value);
+ }
+
+ private matcher(m: LabelMatcher): string {
+ return `${m.name}${m.op}${JSON.stringify(m.value)}`;
+ }
+
+ private selectorHead(name: string | undefined, matchers: LabelMatcher[]): string {
+ let head = name ?? '';
+ if (matchers.length > 0) {
+ head += `{${matchers.map((m) => this.matcher(m)).join(', ')}}`;
+ } else if (!name) {
+ head += '{}';
+ }
+ return head;
+ }
+
+ private atModifier(at: AtModifier): string {
+ switch (at.kind) {
+ case 'start':
+ return ' @ start()';
+ case 'end':
+ return ' @ end()';
+ case 'timestamp':
+ return ` @ ${at.value}`;
+ }
+ }
+
+ private offset(offset: string): string {
+ return ` offset ${offset}`;
+ }
+
+ private vectorSelector(n: VectorSelector): string {
+ let out = this.selectorHead(n.name, n.matchers);
+ if (n.at) out += this.atModifier(n.at);
+ if (n.offset) out += this.offset(n.offset);
+ return out;
+ }
+
+ private matrixSelector(n: MatrixSelector): string {
+ let out = this.selectorHead(n.vectorSelector.name, n.vectorSelector.matchers);
+ out += `[${n.range}]`;
+ if (n.at) out += this.atModifier(n.at);
+ if (n.offset) out += this.offset(n.offset);
+ return out;
+ }
+
+ private subquery(n: SubqueryExpr): string {
+ let out = this.deparse(n.expr);
+ out += `[${n.range}:${n.step ?? ''}]`;
+ if (n.at) out += this.atModifier(n.at);
+ if (n.offset) out += this.offset(n.offset);
+ return out;
+ }
+
+ private paren(n: ParenExpr): string {
+ return `(${this.deparse(n.expr)})`;
+ }
+
+ private unary(n: UnaryExpr): string {
+ const operand = nodePrec(n.expr) < UNARY_PREC ? `(${this.deparse(n.expr)})` : this.deparse(n.expr);
+ return `${n.op}${operand}`;
+ }
+
+ private binary(n: BinaryExpr): string {
+ const p = binOpPrec(n.op);
+ const rightAssoc = isRightAssoc(n.op);
+
+ const lp = nodePrec(n.lhs);
+ const rp = nodePrec(n.rhs);
+
+ const lhsParen = lp < p || (lp === p && rightAssoc);
+ const rhsParen = rp < p || (rp === p && !rightAssoc);
+
+ const lhs = lhsParen ? `(${this.deparse(n.lhs)})` : this.deparse(n.lhs);
+ const rhs = rhsParen ? `(${this.deparse(n.rhs)})` : this.deparse(n.rhs);
+
+ let mid = n.op;
+ if (n.bool) mid += ' bool';
+ if (n.matching) mid += this.matching(n.matching);
+
+ return `${lhs} ${mid} ${rhs}`;
+ }
+
+ private matching(m: VectorMatching): string {
+ let out = '';
+ if (m.on) out += ` on (${m.on.join(', ')})`;
+ else if (m.ignoring) out += ` ignoring (${m.ignoring.join(', ')})`;
+
+ if (m.groupLeft) out += m.groupLeft.length ? ` group_left (${m.groupLeft.join(', ')})` : ' group_left';
+ else if (m.groupRight)
+ out += m.groupRight.length ? ` group_right (${m.groupRight.join(', ')})` : ' group_right';
+
+ return out;
+ }
+
+ private aggregate(n: AggregateExpr): string {
+ const inner = n.param ? `${this.deparse(n.param)}, ${this.deparse(n.expr)}` : this.deparse(n.expr);
+ if (n.grouping) {
+ const g = `${n.grouping.modifier} (${n.grouping.labels.join(', ')})`;
+ return `${n.op} ${g} (${inner})`;
+ }
+ return `${n.op}(${inner})`;
+ }
+
+ private call(n: Call): string {
+ return `${n.func}(${n.args.map((a) => this.deparse(a)).join(', ')})`;
+ }
+}
+
+/** Deparse a PromQL AST to a string. */
+export function deparse(node: Expr): string {
+ return new Deparser().deparse(node);
+}
diff --git a/packages/promql/src/index.ts b/packages/promql/src/index.ts
new file mode 100644
index 0000000..c31169c
--- /dev/null
+++ b/packages/promql/src/index.ts
@@ -0,0 +1,18 @@
+// Types
+export * from './types';
+
+// Lexer
+export { Lexer, tokenize, TokenType } from './lexer';
+export type { Token } from './lexer';
+
+// Parser
+export { parse, Parser } from './parser';
+
+// Deparser
+export { deparse, Deparser } from './deparser';
+
+// Builders
+export * from './builders';
+
+// Utilities
+export { astEqual, cleanTree, printAst } from './clean';
diff --git a/packages/promql/src/lexer.ts b/packages/promql/src/lexer.ts
new file mode 100644
index 0000000..54c43bb
--- /dev/null
+++ b/packages/promql/src/lexer.ts
@@ -0,0 +1,335 @@
+import type { Position, Range } from './types';
+
+/**
+ * Token types for PromQL.
+ */
+export enum TokenType {
+ NUMBER = 'NUMBER',
+ STRING = 'STRING',
+ IDENTIFIER = 'IDENTIFIER',
+ DURATION = 'DURATION',
+
+ LEFT_PAREN = 'LEFT_PAREN',
+ RIGHT_PAREN = 'RIGHT_PAREN',
+ LEFT_BRACE = 'LEFT_BRACE',
+ RIGHT_BRACE = 'RIGHT_BRACE',
+ LEFT_BRACKET = 'LEFT_BRACKET',
+ RIGHT_BRACKET = 'RIGHT_BRACKET',
+ COMMA = 'COMMA',
+ COLON = 'COLON',
+ AT = 'AT',
+
+ ADD = 'ADD',
+ SUB = 'SUB',
+ MUL = 'MUL',
+ DIV = 'DIV',
+ MOD = 'MOD',
+ POW = 'POW',
+
+ EQLC = 'EQLC', // ==
+ NEQ = 'NEQ', // !=
+ LTE = 'LTE', // <=
+ GTE = 'GTE', // >=
+ LT = 'LT', // <
+ GT = 'GT', // >
+
+ EQL = 'EQL', // =
+ EQL_REGEX = 'EQL_REGEX', // =~
+ NEQ_REGEX = 'NEQ_REGEX', // !~
+
+ EOF = 'EOF',
+}
+
+export interface Token {
+ type: TokenType;
+ value: string;
+ range: Range;
+}
+
+const DURATION_RE = /^([0-9]+(ms|[smhdwy]))+/;
+
+function isIdentStart(ch: string): boolean {
+ return /[a-zA-Z_]/.test(ch);
+}
+function isIdentPart(ch: string): boolean {
+ return /[a-zA-Z0-9_]/.test(ch);
+}
+function isDigit(ch: string): boolean {
+ return ch >= '0' && ch <= '9';
+}
+
+/**
+ * Lexer for PromQL. Bracket-aware: `:` is a subquery separator inside `[...]`
+ * and part of a (recording-rule) metric name outside of it.
+ */
+export class Lexer {
+ private input: string;
+ private pos = 0;
+ private line = 1;
+ private col = 1;
+ private bracketDepth = 0;
+
+ constructor(input: string) {
+ this.input = input;
+ }
+
+ tokenize(): Token[] {
+ const tokens: Token[] = [];
+ let tok = this.next();
+ while (tok.type !== TokenType.EOF) {
+ tokens.push(tok);
+ tok = this.next();
+ }
+ tokens.push(tok); // EOF
+ return tokens;
+ }
+
+ private position(): Position {
+ return { line: this.line, column: this.col, offset: this.pos };
+ }
+
+ private advance(n = 1): void {
+ for (let i = 0; i < n; i++) {
+ if (this.input[this.pos] === '\n') {
+ this.line++;
+ this.col = 1;
+ } else {
+ this.col++;
+ }
+ this.pos++;
+ }
+ }
+
+ private makeToken(type: TokenType, value: string, start: Position): Token {
+ return { type, value, range: { start, end: this.position() } };
+ }
+
+ private skipTrivia(): void {
+ for (;;) {
+ const ch = this.input[this.pos];
+ if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') {
+ this.advance();
+ } else if (ch === '#') {
+ while (this.pos < this.input.length && this.input[this.pos] !== '\n') this.advance();
+ } else {
+ break;
+ }
+ }
+ }
+
+ private next(): Token {
+ this.skipTrivia();
+ const start = this.position();
+ if (this.pos >= this.input.length) return this.makeToken(TokenType.EOF, '', start);
+
+ const ch = this.input[this.pos];
+ const two = this.input.slice(this.pos, this.pos + 2);
+
+ // Multi-char operators
+ switch (two) {
+ case '==':
+ this.advance(2);
+ return this.makeToken(TokenType.EQLC, '==', start);
+ case '!=':
+ this.advance(2);
+ return this.makeToken(TokenType.NEQ, '!=', start);
+ case '<=':
+ this.advance(2);
+ return this.makeToken(TokenType.LTE, '<=', start);
+ case '>=':
+ this.advance(2);
+ return this.makeToken(TokenType.GTE, '>=', start);
+ case '=~':
+ this.advance(2);
+ return this.makeToken(TokenType.EQL_REGEX, '=~', start);
+ case '!~':
+ this.advance(2);
+ return this.makeToken(TokenType.NEQ_REGEX, '!~', start);
+ default:
+ break;
+ }
+
+ // Single-char structural / operators
+ switch (ch) {
+ case '(':
+ this.advance();
+ return this.makeToken(TokenType.LEFT_PAREN, '(', start);
+ case ')':
+ this.advance();
+ return this.makeToken(TokenType.RIGHT_PAREN, ')', start);
+ case '{':
+ this.advance();
+ return this.makeToken(TokenType.LEFT_BRACE, '{', start);
+ case '}':
+ this.advance();
+ return this.makeToken(TokenType.RIGHT_BRACE, '}', start);
+ case '[':
+ this.bracketDepth++;
+ this.advance();
+ return this.makeToken(TokenType.LEFT_BRACKET, '[', start);
+ case ']':
+ if (this.bracketDepth > 0) this.bracketDepth--;
+ this.advance();
+ return this.makeToken(TokenType.RIGHT_BRACKET, ']', start);
+ case ',':
+ this.advance();
+ return this.makeToken(TokenType.COMMA, ',', start);
+ case ':':
+ this.advance();
+ return this.makeToken(TokenType.COLON, ':', start);
+ case '@':
+ this.advance();
+ return this.makeToken(TokenType.AT, '@', start);
+ case '+':
+ this.advance();
+ return this.makeToken(TokenType.ADD, '+', start);
+ case '-':
+ this.advance();
+ return this.makeToken(TokenType.SUB, '-', start);
+ case '*':
+ this.advance();
+ return this.makeToken(TokenType.MUL, '*', start);
+ case '/':
+ this.advance();
+ return this.makeToken(TokenType.DIV, '/', start);
+ case '%':
+ this.advance();
+ return this.makeToken(TokenType.MOD, '%', start);
+ case '^':
+ this.advance();
+ return this.makeToken(TokenType.POW, '^', start);
+ case '=':
+ this.advance();
+ return this.makeToken(TokenType.EQL, '=', start);
+ case '<':
+ this.advance();
+ return this.makeToken(TokenType.LT, '<', start);
+ case '>':
+ this.advance();
+ return this.makeToken(TokenType.GT, '>', start);
+ default:
+ break;
+ }
+
+ if (ch === '"' || ch === "'" || ch === '`') return this.readString(start, ch);
+ if (isDigit(ch) || (ch === '.' && isDigit(this.input[this.pos + 1] ?? ''))) {
+ return this.readNumberOrDuration(start);
+ }
+ if (isIdentStart(ch)) return this.readIdentifier(start);
+
+ throw new Error(`Unexpected character '${ch}' at line ${start.line}:${start.column}`);
+ }
+
+ private readString(start: Position, quote: string): Token {
+ this.advance(); // opening quote
+ let value = '';
+ const raw = quote === '`';
+ while (this.pos < this.input.length && this.input[this.pos] !== quote) {
+ const c = this.input[this.pos];
+ if (!raw && c === '\\') {
+ const esc = this.input[this.pos + 1];
+ this.advance(2);
+ switch (esc) {
+ case 'n':
+ value += '\n';
+ break;
+ case 't':
+ value += '\t';
+ break;
+ case 'r':
+ value += '\r';
+ break;
+ case '\\':
+ value += '\\';
+ break;
+ case '"':
+ value += '"';
+ break;
+ case "'":
+ value += "'";
+ break;
+ default:
+ value += esc ?? '';
+ }
+ } else {
+ value += c;
+ this.advance();
+ }
+ }
+ if (this.pos >= this.input.length) {
+ throw new Error(`Unterminated string starting at line ${start.line}:${start.column}`);
+ }
+ this.advance(); // closing quote
+ return this.makeToken(TokenType.STRING, value, start);
+ }
+
+ private readNumberOrDuration(start: Position): Token {
+ const rest = this.input.slice(this.pos);
+ const durMatch = DURATION_RE.exec(rest);
+ // Only a duration when the digits are immediately followed by a unit
+ // (i.e. the match consumed more than just the leading digits).
+ if (durMatch && /[a-z]/.test(durMatch[0])) {
+ const value = durMatch[0];
+ this.advance(value.length);
+ return this.makeToken(TokenType.DURATION, value, start);
+ }
+
+ let value = '';
+ // hex
+ if (rest.startsWith('0x') || rest.startsWith('0X')) {
+ value = '0x';
+ this.advance(2);
+ while (/[0-9a-fA-F]/.test(this.input[this.pos] ?? '')) {
+ value += this.input[this.pos];
+ this.advance();
+ }
+ return this.makeToken(TokenType.NUMBER, value, start);
+ }
+ while (isDigit(this.input[this.pos] ?? '')) {
+ value += this.input[this.pos];
+ this.advance();
+ }
+ if (this.input[this.pos] === '.') {
+ value += '.';
+ this.advance();
+ while (isDigit(this.input[this.pos] ?? '')) {
+ value += this.input[this.pos];
+ this.advance();
+ }
+ }
+ if (this.input[this.pos] === 'e' || this.input[this.pos] === 'E') {
+ value += this.input[this.pos];
+ this.advance();
+ if (this.input[this.pos] === '+' || this.input[this.pos] === '-') {
+ value += this.input[this.pos];
+ this.advance();
+ }
+ while (isDigit(this.input[this.pos] ?? '')) {
+ value += this.input[this.pos];
+ this.advance();
+ }
+ }
+ return this.makeToken(TokenType.NUMBER, value, start);
+ }
+
+ private readIdentifier(start: Position): Token {
+ let value = '';
+ // Metric names may contain ':' but only outside bracket (subquery) context.
+ const allowColon = this.bracketDepth === 0;
+ while (this.pos < this.input.length) {
+ const c = this.input[this.pos];
+ if (isIdentPart(c) || (allowColon && c === ':')) {
+ value += c;
+ this.advance();
+ } else {
+ break;
+ }
+ }
+ return this.makeToken(TokenType.IDENTIFIER, value, start);
+ }
+}
+
+/** Convenience: tokenize a PromQL string. */
+export function tokenize(input: string): Token[] {
+ return new Lexer(input).tokenize();
+}
diff --git a/packages/promql/src/parser.ts b/packages/promql/src/parser.ts
new file mode 100644
index 0000000..d7e9016
--- /dev/null
+++ b/packages/promql/src/parser.ts
@@ -0,0 +1,502 @@
+import { Lexer, Token, TokenType } from './lexer';
+import type {
+ AggregateExpr,
+ AggregateOp,
+ AtModifier,
+ BinaryOp,
+ Call,
+ Expr,
+ LabelMatcher,
+ MatchOp,
+ VectorMatching,
+ VectorSelector,
+} from './types';
+
+const AGGREGATE_OPS = new Set([
+ 'sum',
+ 'min',
+ 'max',
+ 'avg',
+ 'group',
+ 'stddev',
+ 'stdvar',
+ 'count',
+ 'count_values',
+ 'bottomk',
+ 'topk',
+ 'quantile',
+ 'limitk',
+ 'limit_ratio',
+]);
+
+const AGG_WITH_PARAM = new Set([
+ 'count_values',
+ 'bottomk',
+ 'topk',
+ 'quantile',
+ 'limitk',
+ 'limit_ratio',
+]);
+
+const COMPARISON_OPS = new Set(['==', '!=', '<=', '<', '>=', '>']);
+
+interface BinaryInfo {
+ op: BinaryOp;
+ prec: number;
+ rightAssoc: boolean;
+}
+
+function tokenBinaryInfo(tok: Token): BinaryInfo | null {
+ switch (tok.type) {
+ case TokenType.ADD:
+ return { op: '+', prec: 4, rightAssoc: false };
+ case TokenType.SUB:
+ return { op: '-', prec: 4, rightAssoc: false };
+ case TokenType.MUL:
+ return { op: '*', prec: 5, rightAssoc: false };
+ case TokenType.DIV:
+ return { op: '/', prec: 5, rightAssoc: false };
+ case TokenType.MOD:
+ return { op: '%', prec: 5, rightAssoc: false };
+ case TokenType.POW:
+ return { op: '^', prec: 6, rightAssoc: true };
+ case TokenType.EQLC:
+ return { op: '==', prec: 3, rightAssoc: false };
+ case TokenType.NEQ:
+ return { op: '!=', prec: 3, rightAssoc: false };
+ case TokenType.LTE:
+ return { op: '<=', prec: 3, rightAssoc: false };
+ case TokenType.LT:
+ return { op: '<', prec: 3, rightAssoc: false };
+ case TokenType.GTE:
+ return { op: '>=', prec: 3, rightAssoc: false };
+ case TokenType.GT:
+ return { op: '>', prec: 3, rightAssoc: false };
+ case TokenType.IDENTIFIER:
+ switch (tok.value) {
+ case 'or':
+ return { op: 'or', prec: 1, rightAssoc: false };
+ case 'and':
+ return { op: 'and', prec: 2, rightAssoc: false };
+ case 'unless':
+ return { op: 'unless', prec: 2, rightAssoc: false };
+ case 'atan2':
+ return { op: 'atan2', prec: 5, rightAssoc: false };
+ default:
+ return null;
+ }
+ default:
+ return null;
+ }
+}
+
+/**
+ * Recursive-descent / precedence-climbing parser for PromQL.
+ */
+export class Parser {
+ private tokens: Token[];
+ private pos = 0;
+
+ constructor(input: string) {
+ this.tokens = new Lexer(input).tokenize();
+ }
+
+ parse(): Expr {
+ const expr = this.parseExpr(0);
+ this.expect(TokenType.EOF);
+ return expr;
+ }
+
+ private peek(): Token {
+ return this.tokens[this.pos];
+ }
+
+ private lookahead(n: number): Token {
+ return this.tokens[Math.min(this.pos + n, this.tokens.length - 1)];
+ }
+
+ private advance(): Token {
+ return this.tokens[this.pos++];
+ }
+
+ private check(type: TokenType): boolean {
+ return this.peek().type === type;
+ }
+
+ private checkKeyword(word: string): boolean {
+ const t = this.peek();
+ return t.type === TokenType.IDENTIFIER && t.value === word;
+ }
+
+ private expect(type: TokenType): Token {
+ const t = this.peek();
+ if (t.type !== type) {
+ throw new Error(
+ `Expected ${type} but found ${t.type} ('${t.value}') at line ${t.range.start.line}:${t.range.start.column}`
+ );
+ }
+ return this.advance();
+ }
+
+ private parseExpr(minPrec: number): Expr {
+ let left = this.parseUnary();
+
+ for (;;) {
+ const info = tokenBinaryInfo(this.peek());
+ if (!info || info.prec < minPrec) break;
+
+ this.advance(); // operator
+
+ let bool = false;
+ if (COMPARISON_OPS.has(info.op) && this.checkKeyword('bool')) {
+ this.advance();
+ bool = true;
+ }
+
+ const matching = this.parseVectorMatching();
+
+ const nextMin = info.rightAssoc ? info.prec : info.prec + 1;
+ const right = this.parseExpr(nextMin);
+
+ left = {
+ type: 'BinaryExpr',
+ op: info.op,
+ lhs: left,
+ rhs: right,
+ ...(bool ? { bool } : {}),
+ ...(matching ? { matching } : {}),
+ };
+ }
+
+ return left;
+ }
+
+ private parseUnary(): Expr {
+ const t = this.peek();
+ if (t.type === TokenType.ADD || t.type === TokenType.SUB) {
+ this.advance();
+ const expr = this.parseUnary();
+ return { type: 'UnaryExpr', op: t.type === TokenType.ADD ? '+' : '-', expr };
+ }
+ return this.parsePostfix();
+ }
+
+ private parsePostfix(): Expr {
+ let expr = this.parsePrimary();
+
+ for (;;) {
+ if (this.check(TokenType.LEFT_BRACKET)) {
+ expr = this.parseRangeOrSubquery(expr);
+ } else if (this.check(TokenType.AT)) {
+ this.advance();
+ const at = this.parseAtModifier();
+ expr = this.attachAt(expr, at);
+ } else if (this.checkKeyword('offset')) {
+ this.advance();
+ const offset = this.parseOffsetDuration();
+ expr = this.attachOffset(expr, offset);
+ } else {
+ break;
+ }
+ }
+
+ return expr;
+ }
+
+ private parseRangeOrSubquery(expr: Expr): Expr {
+ this.expect(TokenType.LEFT_BRACKET);
+ const range = this.expect(TokenType.DURATION).value;
+
+ if (this.check(TokenType.COLON)) {
+ this.advance();
+ let step: string | undefined;
+ if (this.check(TokenType.DURATION)) step = this.advance().value;
+ this.expect(TokenType.RIGHT_BRACKET);
+ return { type: 'SubqueryExpr', expr, range, ...(step ? { step } : {}) };
+ }
+
+ this.expect(TokenType.RIGHT_BRACKET);
+ if (expr.type !== 'VectorSelector') {
+ throw new Error('Range vector selector must be applied to a vector selector');
+ }
+ return { type: 'MatrixSelector', vectorSelector: expr, range };
+ }
+
+ private attachAt(expr: Expr, at: AtModifier): Expr {
+ if (
+ expr.type === 'VectorSelector' ||
+ expr.type === 'MatrixSelector' ||
+ expr.type === 'SubqueryExpr'
+ ) {
+ return { ...expr, at };
+ }
+ throw new Error(`@ modifier cannot be applied to ${expr.type}`);
+ }
+
+ private attachOffset(expr: Expr, offset: string): Expr {
+ if (
+ expr.type === 'VectorSelector' ||
+ expr.type === 'MatrixSelector' ||
+ expr.type === 'SubqueryExpr'
+ ) {
+ return { ...expr, offset };
+ }
+ throw new Error(`offset modifier cannot be applied to ${expr.type}`);
+ }
+
+ private parseOffsetDuration(): string {
+ let sign = '';
+ if (this.check(TokenType.SUB)) {
+ this.advance();
+ sign = '-';
+ } else if (this.check(TokenType.ADD)) {
+ this.advance();
+ }
+ return sign + this.expect(TokenType.DURATION).value;
+ }
+
+ private parseAtModifier(): AtModifier {
+ if (this.checkKeyword('start')) {
+ this.advance();
+ this.expect(TokenType.LEFT_PAREN);
+ this.expect(TokenType.RIGHT_PAREN);
+ return { kind: 'start' };
+ }
+ if (this.checkKeyword('end')) {
+ this.advance();
+ this.expect(TokenType.LEFT_PAREN);
+ this.expect(TokenType.RIGHT_PAREN);
+ return { kind: 'end' };
+ }
+ let sign = 1;
+ if (this.check(TokenType.SUB)) {
+ this.advance();
+ sign = -1;
+ } else if (this.check(TokenType.ADD)) {
+ this.advance();
+ }
+ const num = this.expect(TokenType.NUMBER).value;
+ return { kind: 'timestamp', value: sign * parseNumber(num) };
+ }
+
+ private parsePrimary(): Expr {
+ const t = this.peek();
+
+ switch (t.type) {
+ case TokenType.NUMBER:
+ this.advance();
+ return { type: 'NumberLiteral', value: parseNumber(t.value) };
+ case TokenType.STRING:
+ this.advance();
+ return { type: 'StringLiteral', value: t.value };
+ case TokenType.LEFT_PAREN: {
+ this.advance();
+ const inner = this.parseExpr(0);
+ this.expect(TokenType.RIGHT_PAREN);
+ return { type: 'ParenExpr', expr: inner };
+ }
+ case TokenType.LEFT_BRACE: {
+ const matchers = this.parseMatchers();
+ return { type: 'VectorSelector', matchers };
+ }
+ case TokenType.IDENTIFIER:
+ return this.parseIdentifierExpr();
+ default:
+ throw new Error(
+ `Unexpected token ${t.type} ('${t.value}') at line ${t.range.start.line}:${t.range.start.column}`
+ );
+ }
+ }
+
+ private parseIdentifierExpr(): Expr {
+ const nameTok = this.peek();
+ const name = nameTok.value;
+
+ if (name === 'Inf' || name === 'inf' || name === '+Inf') {
+ this.advance();
+ return { type: 'NumberLiteral', value: Infinity };
+ }
+ if (name === 'NaN' || name === 'nan') {
+ this.advance();
+ return { type: 'NumberLiteral', value: NaN };
+ }
+
+ if (AGGREGATE_OPS.has(name as AggregateOp) && this.isAggregateAhead()) {
+ return this.parseAggregate(name as AggregateOp);
+ }
+
+ // Function call
+ if (this.lookahead(1).type === TokenType.LEFT_PAREN) {
+ this.advance(); // name
+ const args = this.parseCallArgs();
+ const call: Call = { type: 'Call', func: name, args };
+ return call;
+ }
+
+ // Vector selector with metric name
+ this.advance(); // name
+ let matchers: LabelMatcher[] = [];
+ if (this.check(TokenType.LEFT_BRACE)) matchers = this.parseMatchers();
+ const sel: VectorSelector = { type: 'VectorSelector', name, matchers };
+ return sel;
+ }
+
+ /**
+ * Aggregations may write the grouping clause before the args
+ * (`sum by (x) (...)`) or after (`sum(...) by (x)`). Either way the token
+ * right after the op is `(` or a by/without keyword.
+ */
+ private isAggregateAhead(): boolean {
+ const nxt = this.lookahead(1);
+ if (nxt.type === TokenType.LEFT_PAREN) return true;
+ return nxt.type === TokenType.IDENTIFIER && (nxt.value === 'by' || nxt.value === 'without');
+ }
+
+ private parseAggregate(op: AggregateOp): AggregateExpr {
+ this.advance(); // op
+
+ let grouping: AggregateExpr['grouping'];
+ if (this.checkKeyword('by') || this.checkKeyword('without')) {
+ grouping = this.parseGrouping();
+ }
+
+ this.expect(TokenType.LEFT_PAREN);
+ const args: Expr[] = [];
+ if (!this.check(TokenType.RIGHT_PAREN)) {
+ args.push(this.parseExpr(0));
+ while (this.check(TokenType.COMMA)) {
+ this.advance();
+ args.push(this.parseExpr(0));
+ }
+ }
+ this.expect(TokenType.RIGHT_PAREN);
+
+ if (!grouping && (this.checkKeyword('by') || this.checkKeyword('without'))) {
+ grouping = this.parseGrouping();
+ }
+
+ let param: Expr | undefined;
+ let expr: Expr;
+ if (AGG_WITH_PARAM.has(op) && args.length === 2) {
+ param = args[0];
+ expr = args[1];
+ } else {
+ expr = args[args.length - 1];
+ }
+
+ return {
+ type: 'AggregateExpr',
+ op,
+ expr,
+ ...(param ? { param } : {}),
+ ...(grouping ? { grouping } : {}),
+ };
+ }
+
+ private parseGrouping(): { modifier: 'by' | 'without'; labels: string[] } {
+ const modifier = this.advance().value as 'by' | 'without';
+ const labels = this.parseLabelList();
+ return { modifier, labels };
+ }
+
+ private parseVectorMatching(): VectorMatching | undefined {
+ const matching: VectorMatching = {};
+ let has = false;
+
+ if (this.checkKeyword('on')) {
+ this.advance();
+ matching.on = this.parseLabelList();
+ has = true;
+ } else if (this.checkKeyword('ignoring')) {
+ this.advance();
+ matching.ignoring = this.parseLabelList();
+ has = true;
+ }
+
+ if (this.checkKeyword('group_left')) {
+ this.advance();
+ matching.groupLeft = this.check(TokenType.LEFT_PAREN) ? this.parseLabelList() : [];
+ has = true;
+ } else if (this.checkKeyword('group_right')) {
+ this.advance();
+ matching.groupRight = this.check(TokenType.LEFT_PAREN) ? this.parseLabelList() : [];
+ has = true;
+ }
+
+ return has ? matching : undefined;
+ }
+
+ private parseLabelList(): string[] {
+ this.expect(TokenType.LEFT_PAREN);
+ const labels: string[] = [];
+ if (!this.check(TokenType.RIGHT_PAREN)) {
+ labels.push(this.expect(TokenType.IDENTIFIER).value);
+ while (this.check(TokenType.COMMA)) {
+ this.advance();
+ labels.push(this.expect(TokenType.IDENTIFIER).value);
+ }
+ }
+ this.expect(TokenType.RIGHT_PAREN);
+ return labels;
+ }
+
+ private parseCallArgs(): Expr[] {
+ this.expect(TokenType.LEFT_PAREN);
+ const args: Expr[] = [];
+ if (!this.check(TokenType.RIGHT_PAREN)) {
+ args.push(this.parseExpr(0));
+ while (this.check(TokenType.COMMA)) {
+ this.advance();
+ args.push(this.parseExpr(0));
+ }
+ }
+ this.expect(TokenType.RIGHT_PAREN);
+ return args;
+ }
+
+ private parseMatchers(): LabelMatcher[] {
+ this.expect(TokenType.LEFT_BRACE);
+ const matchers: LabelMatcher[] = [];
+ while (!this.check(TokenType.RIGHT_BRACE)) {
+ const name = this.expect(TokenType.IDENTIFIER).value;
+ const op = this.parseMatchOp();
+ const value = this.expect(TokenType.STRING).value;
+ matchers.push({ name, op, value });
+ if (this.check(TokenType.COMMA)) {
+ this.advance();
+ } else {
+ break;
+ }
+ }
+ this.expect(TokenType.RIGHT_BRACE);
+ return matchers;
+ }
+
+ private parseMatchOp(): MatchOp {
+ const t = this.advance();
+ switch (t.type) {
+ case TokenType.EQL:
+ return '=';
+ case TokenType.NEQ:
+ return '!=';
+ case TokenType.EQL_REGEX:
+ return '=~';
+ case TokenType.NEQ_REGEX:
+ return '!~';
+ default:
+ throw new Error(
+ `Expected label matcher operator but found '${t.value}' at line ${t.range.start.line}:${t.range.start.column}`
+ );
+ }
+ }
+}
+
+function parseNumber(raw: string): number {
+ if (raw.startsWith('0x') || raw.startsWith('0X')) return parseInt(raw, 16);
+ return Number(raw);
+}
+
+/** Parse a PromQL expression string into an AST. */
+export function parse(input: string): Expr {
+ return new Parser(input).parse();
+}
diff --git a/packages/promql/src/types.ts b/packages/promql/src/types.ts
new file mode 100644
index 0000000..d821fa0
--- /dev/null
+++ b/packages/promql/src/types.ts
@@ -0,0 +1,194 @@
+/**
+ * Position represents a line and column in the source text.
+ */
+export interface Position {
+ line: number;
+ column: number;
+ offset: number;
+}
+
+/**
+ * Range represents a span in the source text.
+ */
+export interface Range {
+ start: Position;
+ end: Position;
+}
+
+/**
+ * Base node type for all AST nodes.
+ */
+export interface BaseNode {
+ type: string;
+ /** Optional source location. Named `loc` (not `range`) to avoid colliding with
+ * the semantic `range` duration field on matrix selectors / subqueries. */
+ loc?: Range;
+}
+
+/** Label matcher operators. */
+export type MatchOp = '=' | '!=' | '=~' | '!~';
+
+/** Binary operators, in PromQL surface syntax. */
+export type BinaryOp =
+ | '+'
+ | '-'
+ | '*'
+ | '/'
+ | '%'
+ | '^'
+ | '=='
+ | '!='
+ | '>'
+ | '<'
+ | '>='
+ | '<='
+ | 'and'
+ | 'or'
+ | 'unless'
+ | 'atan2';
+
+/** Aggregation operators. */
+export type AggregateOp =
+ | 'sum'
+ | 'min'
+ | 'max'
+ | 'avg'
+ | 'group'
+ | 'stddev'
+ | 'stdvar'
+ | 'count'
+ | 'count_values'
+ | 'bottomk'
+ | 'topk'
+ | 'quantile'
+ | 'limitk'
+ | 'limit_ratio';
+
+/**
+ * The `@` modifier: an absolute evaluation timestamp, or `start()` / `end()`.
+ */
+export type AtModifier = { kind: 'timestamp'; value: number } | { kind: 'start' } | { kind: 'end' };
+
+/**
+ * A single label matcher inside a vector selector, e.g. `job="api"`.
+ */
+export interface LabelMatcher {
+ name: string;
+ op: MatchOp;
+ value: string;
+}
+
+/**
+ * Vector matching clause for binary operators:
+ * `on(...) / ignoring(...)` plus optional `group_left(...) / group_right(...)`.
+ */
+export interface VectorMatching {
+ on?: string[];
+ ignoring?: string[];
+ groupLeft?: string[];
+ groupRight?: string[];
+}
+
+/** A numeric literal (supports `Inf`, `+Inf`, `-Inf`, `NaN`). */
+export interface NumberLiteral extends BaseNode {
+ type: 'NumberLiteral';
+ value: number;
+}
+
+/** A string literal. `value` is the unquoted, unescaped content. */
+export interface StringLiteral extends BaseNode {
+ type: 'StringLiteral';
+ value: string;
+}
+
+/**
+ * An instant vector selector, e.g. `http_requests_total{job="api"} offset 5m`.
+ * `name` is the metric name (may also be encoded as a `__name__` matcher).
+ */
+export interface VectorSelector extends BaseNode {
+ type: 'VectorSelector';
+ name?: string;
+ matchers: LabelMatcher[];
+ offset?: string;
+ at?: AtModifier;
+}
+
+/**
+ * A range vector selector, e.g. `rate(x[5m])`'s `x[5m]`. The range window is a
+ * PromQL duration string (e.g. `5m`, `1h30m`).
+ */
+export interface MatrixSelector extends BaseNode {
+ type: 'MatrixSelector';
+ vectorSelector: VectorSelector;
+ range: string;
+ offset?: string;
+ at?: AtModifier;
+}
+
+/**
+ * A subquery, e.g. `rate(x[5m])[1h:1m]`. `step` is optional (default step).
+ */
+export interface SubqueryExpr extends BaseNode {
+ type: 'SubqueryExpr';
+ expr: Expr;
+ range: string;
+ step?: string;
+ offset?: string;
+ at?: AtModifier;
+}
+
+/** A parenthesized expression. */
+export interface ParenExpr extends BaseNode {
+ type: 'ParenExpr';
+ expr: Expr;
+}
+
+/** A unary expression (`-x`, `+x`). */
+export interface UnaryExpr extends BaseNode {
+ type: 'UnaryExpr';
+ op: '+' | '-';
+ expr: Expr;
+}
+
+/** A binary expression with optional vector matching and `bool` modifier. */
+export interface BinaryExpr extends BaseNode {
+ type: 'BinaryExpr';
+ op: BinaryOp;
+ lhs: Expr;
+ rhs: Expr;
+ matching?: VectorMatching;
+ bool?: boolean;
+}
+
+/** An aggregation, e.g. `sum by (job) (rate(x[5m]))` or `topk(3, x)`. */
+export interface AggregateExpr extends BaseNode {
+ type: 'AggregateExpr';
+ op: AggregateOp;
+ expr: Expr;
+ /** Leading parameter for `topk`/`bottomk`/`quantile`/`count_values`/`limitk`/`limit_ratio`. */
+ param?: Expr;
+ grouping?: { modifier: 'by' | 'without'; labels: string[] };
+}
+
+/** A function call, e.g. `rate(x[5m])`, `histogram_quantile(0.9, x)`. */
+export interface Call extends BaseNode {
+ type: 'Call';
+ func: string;
+ args: Expr[];
+}
+
+/** Any PromQL expression node. */
+export type Expr =
+ | NumberLiteral
+ | StringLiteral
+ | VectorSelector
+ | MatrixSelector
+ | SubqueryExpr
+ | ParenExpr
+ | UnaryExpr
+ | BinaryExpr
+ | AggregateExpr
+ | Call;
+
+/** Union of every AST node (used by tree utilities). */
+export type AstNode = Expr | LabelMatcher;
diff --git a/packages/promql/test-utils/index.ts b/packages/promql/test-utils/index.ts
new file mode 100644
index 0000000..eb6e501
--- /dev/null
+++ b/packages/promql/test-utils/index.ts
@@ -0,0 +1,13 @@
+import { cleanTree } from '../src/clean';
+import { deparse } from '../src/deparser';
+import { parse } from '../src/parser';
+
+/**
+ * Assert that parse → deparse → parse produces a structurally identical AST.
+ */
+export function roundtrip(input: string): { first: unknown; second: unknown; output: string } {
+ const ast1 = parse(input);
+ const output = deparse(ast1);
+ const ast2 = parse(output);
+ return { first: cleanTree(ast1), second: cleanTree(ast2), output };
+}
diff --git a/packages/promql/tsconfig.esm.json b/packages/promql/tsconfig.esm.json
new file mode 100644
index 0000000..800d750
--- /dev/null
+++ b/packages/promql/tsconfig.esm.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist/esm",
+ "module": "es2022",
+ "rootDir": "src/",
+ "declaration": false
+ }
+}
diff --git a/packages/promql/tsconfig.json b/packages/promql/tsconfig.json
new file mode 100644
index 0000000..1a9d569
--- /dev/null
+++ b/packages/promql/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 8af6704..a85e8f0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -348,6 +348,13 @@ importers:
version: 0.1.10
publishDirectory: dist
+ packages/promql:
+ devDependencies:
+ makage:
+ specifier: 0.1.10
+ version: 0.1.10
+ publishDirectory: dist
+
packages/rego-deparser:
devDependencies:
makage:
@@ -1021,24 +1028,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@nx/nx-linux-arm64-musl@20.8.3':
resolution: {integrity: sha512-LTTGzI8YVPlF1v0YlVf+exM+1q7rpsiUbjTTHJcfHFRU5t4BsiZD54K19Y1UBg1XFx5cwhEaIomSmJ88RwPPVQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@nx/nx-linux-x64-gnu@20.8.3':
resolution: {integrity: sha512-SlA4GtXvQbSzSIWLgiIiLBOjdINPOUR/im+TUbaEMZ8wiGrOY8cnk0PVt95TIQJVBeXBCeb5HnoY0lHJpMOODg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@nx/nx-linux-x64-musl@20.8.3':
resolution: {integrity: sha512-MNzkEwPktp5SQH9dJDH2wP9hgG9LsBDhKJXJfKw6sUI/6qz5+/aAjFziKy+zBnhU4AO1yXt5qEWzR8lDcIriVQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@nx/nx-win32-arm64-msvc@20.8.3':
resolution: {integrity: sha512-qUV7CyXKwRCM/lkvyS6Xa1MqgAuK5da6w27RAehh7LATBUKn1I4/M7DGn6L7ERCxpZuh1TrDz9pUzEy0R+Ekkg==}
@@ -1372,41 +1383,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}