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==}