@fc-components/monaco-editor
Advanced tools
| import * as monaco from 'monaco-editor'; | ||
| import type { Situation } from '../parser/types'; | ||
| import type { LabelMatcher } from '../types'; | ||
| export interface BuildCompletionsArgs { | ||
| situation: Situation; | ||
| range: monaco.IRange; | ||
| variables: string[]; | ||
| currentMatchers: LabelMatcher[]; | ||
| fetchLabelNames?: (currentMatchers: LabelMatcher[]) => Promise<string[]>; | ||
| fetchLabelValues?: (labelName: string, currentMatchers: LabelMatcher[]) => Promise<string[]>; | ||
| } | ||
| export declare function buildCompletions(args: BuildCompletionsArgs): Promise<monaco.languages.CompletionItem[]>; |
| import * as monaco from 'monaco-editor'; | ||
| import type { LabelMatcher } from '../types'; | ||
| import type { MutableRefObject } from 'react'; | ||
| export interface ProviderOptions { | ||
| fetchLabelNamesRef: MutableRefObject<((currentMatchers: LabelMatcher[]) => Promise<string[]>) | undefined>; | ||
| fetchLabelValuesRef: MutableRefObject<((labelName: string, currentMatchers: LabelMatcher[]) => Promise<string[]>) | undefined>; | ||
| variablesRef: MutableRefObject<string[]>; | ||
| } | ||
| export declare function getLokiCompletionProvider(opts: ProviderOptions): monaco.languages.CompletionItemProvider; |
| import React from 'react'; | ||
| import type { LokiEditorProps } from './types'; | ||
| export type { LokiEditorProps, LabelMatcher } from './types'; | ||
| declare const LokiMonacoEditor: React.FC<LokiEditorProps>; | ||
| export default LokiMonacoEditor; | ||
| export { parse, locate } from './parser'; |
| import type { languages } from 'monaco-editor'; | ||
| export declare const languageConfiguration: languages.LanguageConfiguration; |
| import type { languages } from 'monaco-editor'; | ||
| export declare function buildMonarchLanguage(): languages.IMonarchLanguage; |
| export { tokenize } from './lexer'; | ||
| export { parse } from './parser'; | ||
| export { locate } from './locate'; | ||
| export { TokenKind } from './types'; | ||
| export type { Token, LokiQuery, Situation, SituationKind, PipelineStage, StreamSelector, Matcher } from './types'; |
| import type { Token } from './types'; | ||
| /** | ||
| * Convert an offset (0-based) to Monaco line/column. | ||
| * Monaco line/column are 1-based. | ||
| */ | ||
| export declare function offsetToPosition(input: string, offset: number): { | ||
| lineNumber: number; | ||
| column: number; | ||
| }; | ||
| export declare function tokenize(input: string): Token[]; | ||
| /** Return tokens with whitespace and comments filtered out. */ | ||
| export declare function nonWhitespace(tokens: Token[]): Token[]; |
| import type { Token, LokiQuery, Situation } from './types'; | ||
| export declare function locate(offset: number, tokens: Token[]): Situation; | ||
| export declare function parseAndLocate(input: string, offset: number): { | ||
| ast: LokiQuery; | ||
| tokens: Token[]; | ||
| situation: Situation; | ||
| }; |
| import type { LokiQuery } from './types'; | ||
| export declare function parse(input: string): LokiQuery; |
| export declare enum TokenKind { | ||
| LCurly = 0, | ||
| RCurly = 1, | ||
| Pipe = 2, | ||
| PipeEquals = 3, | ||
| PipeRegex = 4, | ||
| Equals = 5, | ||
| NotEquals = 6, | ||
| RegexEquals = 7, | ||
| RegexNotEquals = 8, | ||
| GreaterThan = 9, | ||
| GreaterEquals = 10, | ||
| LessThan = 11, | ||
| LessEquals = 12, | ||
| String = 13, | ||
| Number = 14, | ||
| Duration = 15, | ||
| Bytes = 16, | ||
| Ident = 17, | ||
| Comma = 18, | ||
| LParen = 19, | ||
| RParen = 20, | ||
| LBracket = 21, | ||
| RBracket = 22, | ||
| Comment = 23, | ||
| Whitespace = 24, | ||
| EOF = 25, | ||
| Error = 26 | ||
| } | ||
| export interface Token { | ||
| kind: TokenKind; | ||
| value: string; | ||
| start: number; | ||
| end: number; | ||
| } | ||
| export interface LokiQuery { | ||
| streamSelector: StreamSelector | null; | ||
| pipeline: PipelineStage[]; | ||
| metricExpr: MetricExpr | null; | ||
| errors: ParseError[]; | ||
| } | ||
| export interface StreamSelector { | ||
| matchers: Matcher[]; | ||
| } | ||
| export interface Matcher { | ||
| label: string; | ||
| operator: string; | ||
| value: string; | ||
| } | ||
| export declare type PipelineStage = LineFilter | JsonParser | LogfmtParser | RegexpParser | LabelFilter | UnrecognizedStage; | ||
| export interface LineFilter { | ||
| type: 'lineFilter'; | ||
| operator: string; | ||
| value: string; | ||
| } | ||
| export interface JsonParser { | ||
| type: 'jsonParser'; | ||
| expressions?: string[]; | ||
| } | ||
| export interface LogfmtParser { | ||
| type: 'logfmtParser'; | ||
| expressions?: string[]; | ||
| } | ||
| export interface RegexpParser { | ||
| type: 'regexpParser'; | ||
| pattern: string; | ||
| } | ||
| export interface LabelFilter { | ||
| type: 'labelFilter'; | ||
| conditions: LabelFilterCondition[]; | ||
| } | ||
| export interface LabelFilterCondition { | ||
| label: string; | ||
| operator: string; | ||
| value: string; | ||
| } | ||
| export interface UnrecognizedStage { | ||
| type: 'unrecognized'; | ||
| keyword: string; | ||
| rest: string; | ||
| } | ||
| export interface MetricExpr { | ||
| func: string; | ||
| range: string; | ||
| grouping?: Grouping; | ||
| } | ||
| export interface Grouping { | ||
| by: boolean; | ||
| labels: string[]; | ||
| } | ||
| export interface ParseError { | ||
| message: string; | ||
| start: number; | ||
| end: number; | ||
| } | ||
| export declare type SituationKind = 'NONE' | 'IN_SELECTOR_LABEL' | 'IN_SELECTOR_VALUE' | 'AFTER_PIPE' | 'IN_PIPELINE_ARG' | 'IN_LABEL_FILTER' | 'IN_FUNCTION_PARENS' | 'IN_GROUPING'; | ||
| export interface Situation { | ||
| kind: SituationKind; | ||
| prefix: string; | ||
| replaceRange: { | ||
| start: number; | ||
| end: number; | ||
| }; | ||
| /** The label name when completing a selector value (IN_SELECTOR_VALUE). */ | ||
| labelName?: string; | ||
| /** The index of the matcher being edited within the stream selector (IN_SELECTOR_VALUE). */ | ||
| matcherIndex?: number; | ||
| /** True when cursor is inside an existing string literal (quotes already present). */ | ||
| insideString?: boolean; | ||
| } |
| import type monaco from 'monaco-editor'; | ||
| /** A single label matcher from the stream selector, e.g. {job="nginx"}. */ | ||
| export interface LabelMatcher { | ||
| label: string; | ||
| operator: string; | ||
| value: string; | ||
| } | ||
| export interface LokiEditorProps { | ||
| className?: string; | ||
| maxHeight?: number | string; | ||
| fontSize?: number; | ||
| size?: 'small' | 'middle' | 'large'; | ||
| theme?: 'light' | 'dark'; | ||
| value?: string; | ||
| placeholder?: string; | ||
| enableAutocomplete?: boolean; | ||
| readOnly?: boolean; | ||
| disabled?: boolean; | ||
| variables?: string[]; | ||
| onChange?: (value: string) => void; | ||
| onEnter?: (value: string) => void; | ||
| onBlur?: (value: string) => void; | ||
| onFocus?: (value: string) => void; | ||
| editorDidMount?: (editor: monaco.editor.IStandaloneCodeEditor) => void; | ||
| /** Async provider for label names used in stream selector completion. Receives current matchers for context. */ | ||
| fetchLabelNames?: (currentMatchers: LabelMatcher[]) => Promise<string[]>; | ||
| /** Async provider for label values used in stream selector completion. Receives current matchers for context. */ | ||
| fetchLabelValues?: (labelName: string, currentMatchers: LabelMatcher[]) => Promise<string[]>; | ||
| } | ||
| export interface LokiCompletionContext { | ||
| prefix: string; | ||
| } |
| import * as monaco from 'monaco-editor'; | ||
| import { buildCompletions } from '../completion/completions'; | ||
| import type { Situation } from '../parser/types'; | ||
| const RANGE: monaco.IRange = { | ||
| startLineNumber: 1, | ||
| startColumn: 1, | ||
| endLineNumber: 1, | ||
| endColumn: 1, | ||
| }; | ||
| function makeSituation(partial: Partial<Situation>): Situation { | ||
| return { | ||
| kind: 'NONE', | ||
| prefix: '', | ||
| replaceRange: { start: 0, end: 0 }, | ||
| ...partial, | ||
| }; | ||
| } | ||
| const MOCK_LABEL_NAMES = ['job', 'app', 'namespace']; | ||
| const MOCK_LABEL_VALUES: Record<string, string[]> = { | ||
| job: ['nginx', 'mysql', 'api'], | ||
| }; | ||
| describe('Loki buildCompletions', () => { | ||
| // ── NONE ── | ||
| it('returns nothing for NONE situation', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'NONE' }), | ||
| range: RANGE, | ||
| variables: ['host'], | ||
| currentMatchers: [], | ||
| }); | ||
| expect(items).toEqual([]); | ||
| }); | ||
| // ── IN_SELECTOR_LABEL ── | ||
| it('returns label names for IN_SELECTOR_LABEL', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'IN_SELECTOR_LABEL' }), | ||
| range: RANGE, | ||
| variables: ['host'], | ||
| currentMatchers: [], | ||
| fetchLabelNames: async () => MOCK_LABEL_NAMES, | ||
| }); | ||
| const labels = items.filter((i) => typeof i.label === 'string' && MOCK_LABEL_NAMES.includes(i.label as string)); | ||
| expect(labels).toHaveLength(3); | ||
| }); | ||
| it('does NOT return variables for IN_SELECTOR_LABEL', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'IN_SELECTOR_LABEL' }), | ||
| range: RANGE, | ||
| variables: ['host'], | ||
| currentMatchers: [], | ||
| }); | ||
| const vars = items.filter((i) => String(i.label).startsWith('${')); | ||
| expect(vars).toHaveLength(0); | ||
| }); | ||
| it('handles fetchLabelNames error gracefully', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'IN_SELECTOR_LABEL' }), | ||
| range: RANGE, | ||
| variables: [], | ||
| currentMatchers: [], | ||
| fetchLabelNames: async () => { | ||
| throw new Error('fail'); | ||
| }, | ||
| }); | ||
| expect(items).toEqual([]); | ||
| }); | ||
| // ── IN_SELECTOR_VALUE ── | ||
| it('returns variables for IN_SELECTOR_VALUE', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'IN_SELECTOR_VALUE', labelName: 'job' }), | ||
| range: RANGE, | ||
| variables: ['host'], | ||
| currentMatchers: [], | ||
| }); | ||
| const vars = items.filter((i) => String(i.label).startsWith('${')); | ||
| expect(vars.length).toBeGreaterThan(0); | ||
| }); | ||
| it('returns label values for IN_SELECTOR_VALUE', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'IN_SELECTOR_VALUE', labelName: 'job' }), | ||
| range: RANGE, | ||
| variables: [], | ||
| currentMatchers: [], | ||
| fetchLabelValues: async (name) => MOCK_LABEL_VALUES[name] ?? [], | ||
| }); | ||
| const vals = items.filter((i) => MOCK_LABEL_VALUES['job'].includes(String(i.label))); | ||
| expect(vals).toHaveLength(3); | ||
| }); | ||
| it('wraps values in quotes when not inside string', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'IN_SELECTOR_VALUE', labelName: 'job' }), | ||
| range: RANGE, | ||
| variables: [], | ||
| currentMatchers: [], | ||
| fetchLabelValues: async () => ['nginx'], | ||
| }); | ||
| const val = items.find((i) => i.label === 'nginx'); | ||
| expect(val?.insertText).toBe('"nginx"'); | ||
| }); | ||
| it('does NOT wrap values in quotes when inside string', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'IN_SELECTOR_VALUE', labelName: 'job', insideString: true }), | ||
| range: RANGE, | ||
| variables: [], | ||
| currentMatchers: [], | ||
| fetchLabelValues: async () => ['nginx'], | ||
| }); | ||
| const val = items.find((i) => i.label === 'nginx'); | ||
| expect(val?.insertText).toBe('nginx'); | ||
| }); | ||
| it('wraps variables in quotes when not inside string', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'IN_SELECTOR_VALUE', labelName: 'job' }), | ||
| range: RANGE, | ||
| variables: ['host'], | ||
| currentMatchers: [], | ||
| }); | ||
| const v = items.find((i) => String(i.label) === '${host}'); | ||
| expect(v?.insertText).toBe('"${host}"'); | ||
| }); | ||
| it('does NOT wrap variables in quotes when inside string', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'IN_SELECTOR_VALUE', labelName: 'job', insideString: true }), | ||
| range: RANGE, | ||
| variables: ['host'], | ||
| currentMatchers: [], | ||
| }); | ||
| const v = items.find((i) => String(i.label) === '${host}'); | ||
| expect(v?.insertText).toBe('${host}'); | ||
| }); | ||
| // ── AFTER_PIPE ── | ||
| it('returns line filter operators for AFTER_PIPE', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'AFTER_PIPE' }), | ||
| range: RANGE, | ||
| variables: ['host'], | ||
| currentMatchers: [], | ||
| }); | ||
| const ops = items.filter((i) => ['|=', '!=', '|~', '!~'].includes(String(i.label))); | ||
| expect(ops).toHaveLength(4); | ||
| }); | ||
| it('returns pipeline stages for AFTER_PIPE', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'AFTER_PIPE' }), | ||
| range: RANGE, | ||
| variables: [], | ||
| currentMatchers: [], | ||
| }); | ||
| const stages = items.filter((i) => ['json', 'logfmt', 'regexp', 'line_format', 'drop', 'keep', 'unpack'].includes(String(i.label))); | ||
| expect(stages.length).toBeGreaterThan(0); | ||
| }); | ||
| it('does NOT return variables for AFTER_PIPE', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'AFTER_PIPE' }), | ||
| range: RANGE, | ||
| variables: ['host'], | ||
| currentMatchers: [], | ||
| }); | ||
| const vars = items.filter((i) => String(i.label).startsWith('${')); | ||
| expect(vars).toHaveLength(0); | ||
| }); | ||
| // ── IN_FUNCTION_PARENS ── | ||
| it('returns metric functions for IN_FUNCTION_PARENS', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'IN_FUNCTION_PARENS' }), | ||
| range: RANGE, | ||
| variables: [], | ||
| currentMatchers: [], | ||
| }); | ||
| const funcs = items.filter((i) => ['rate', 'count_over_time', 'sum_over_time', 'rate_counter'].includes(String(i.label))); | ||
| expect(funcs.length).toBeGreaterThan(0); | ||
| }); | ||
| it('returns aggregation functions for IN_FUNCTION_PARENS', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'IN_FUNCTION_PARENS' }), | ||
| range: RANGE, | ||
| variables: [], | ||
| currentMatchers: [], | ||
| }); | ||
| const aggr = items.filter((i) => ['sum', 'avg', 'min', 'max', 'count', 'topk'].includes(String(i.label))); | ||
| expect(aggr.length).toBeGreaterThan(0); | ||
| }); | ||
| it('does NOT return variables for IN_FUNCTION_PARENS', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'IN_FUNCTION_PARENS' }), | ||
| range: RANGE, | ||
| variables: ['host'], | ||
| currentMatchers: [], | ||
| }); | ||
| const vars = items.filter((i) => String(i.label).startsWith('${')); | ||
| expect(vars).toHaveLength(0); | ||
| }); | ||
| it('does NOT return stream selector snippet for IN_FUNCTION_PARENS', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'IN_FUNCTION_PARENS' }), | ||
| range: RANGE, | ||
| variables: [], | ||
| currentMatchers: [], | ||
| }); | ||
| const snippetItems = items.filter((i) => i.label === '{ }'); | ||
| expect(snippetItems).toHaveLength(0); | ||
| }); | ||
| // ── IN_GROUPING ── | ||
| it('returns label names for IN_GROUPING', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'IN_GROUPING' }), | ||
| range: RANGE, | ||
| variables: [], | ||
| currentMatchers: [], | ||
| fetchLabelNames: async () => MOCK_LABEL_NAMES, | ||
| }); | ||
| const labels = items.filter((i) => MOCK_LABEL_NAMES.includes(String(i.label))); | ||
| expect(labels).toHaveLength(3); | ||
| }); | ||
| // ── IN_LABEL_FILTER ── | ||
| it('returns operators for IN_LABEL_FILTER', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'IN_LABEL_FILTER' }), | ||
| range: RANGE, | ||
| variables: [], | ||
| currentMatchers: [], | ||
| }); | ||
| const ops = items.filter((i) => ['>=', '<=', '>', '<'].includes(String(i.label))); | ||
| expect(ops.length).toBeGreaterThan(0); | ||
| }); | ||
| // ── Completions use plain text (no snippet templates) ── | ||
| it('returns plain text insertText for functions (no snippet)', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'IN_FUNCTION_PARENS' }), | ||
| range: RANGE, | ||
| variables: [], | ||
| currentMatchers: [], | ||
| }); | ||
| const rate = items.find((i) => i.label === 'rate'); | ||
| expect(rate?.insertText).toBe('rate'); | ||
| // No placeholder template markers | ||
| expect(rate?.insertText).not.toContain('${'); | ||
| }); | ||
| it('returns plain text insertText for pipeline keywords (no snippet)', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'AFTER_PIPE' }), | ||
| range: RANGE, | ||
| variables: [], | ||
| currentMatchers: [], | ||
| }); | ||
| const regexpItem = items.find((i) => i.label === 'regexp'); | ||
| expect(regexpItem?.insertText).toBe('regexp '); | ||
| expect(regexpItem?.insertText).not.toContain('${'); | ||
| }); | ||
| it('returns plain text for line filter operators (no snippet)', async () => { | ||
| const items = await buildCompletions({ | ||
| situation: makeSituation({ kind: 'AFTER_PIPE' }), | ||
| range: RANGE, | ||
| variables: [], | ||
| currentMatchers: [], | ||
| }); | ||
| const pipeEq = items.find((i) => i.label === '|='); | ||
| expect(pipeEq?.insertText).toBe('|= '); | ||
| expect(pipeEq?.insertText).not.toContain('${'); | ||
| }); | ||
| }); |
| import { tokenize, nonWhitespace } from '../parser/lexer'; | ||
| import { TokenKind } from '../parser/types'; | ||
| function kinds(input: string): TokenKind[] { | ||
| return nonWhitespace(tokenize(input)) | ||
| .filter((t) => t.kind !== TokenKind.EOF) | ||
| .map((t) => t.kind); | ||
| } | ||
| function values(input: string): string[] { | ||
| return nonWhitespace(tokenize(input)) | ||
| .filter((t) => t.kind !== TokenKind.EOF) | ||
| .map((t) => t.value); | ||
| } | ||
| describe('Loki lexer', () => { | ||
| // ── Basic tokens ── | ||
| it('tokenizes stream selector braces', () => { | ||
| expect(kinds('{}')).toEqual([TokenKind.LCurly, TokenKind.RCurly]); | ||
| }); | ||
| it('tokenizes pipe', () => { | ||
| expect(kinds('|')).toEqual([TokenKind.Pipe]); | ||
| }); | ||
| it('tokenizes parentheses', () => { | ||
| expect(kinds('()')).toEqual([TokenKind.LParen, TokenKind.RParen]); | ||
| }); | ||
| it('tokenizes brackets', () => { | ||
| expect(kinds('[]')).toEqual([TokenKind.LBracket, TokenKind.RBracket]); | ||
| }); | ||
| it('tokenizes comma', () => { | ||
| expect(kinds(',')).toEqual([TokenKind.Comma]); | ||
| }); | ||
| // ── Operators ── | ||
| it('tokenizes = and != and =~ and !~', () => { | ||
| expect(kinds('= != =~ !~')).toEqual([TokenKind.Equals, TokenKind.NotEquals, TokenKind.RegexEquals, TokenKind.RegexNotEquals]); | ||
| }); | ||
| it('tokenizes comparison operators', () => { | ||
| expect(kinds('> >= < <=')).toEqual([TokenKind.GreaterThan, TokenKind.GreaterEquals, TokenKind.LessThan, TokenKind.LessEquals]); | ||
| }); | ||
| // ── Pipe operators ── | ||
| it('tokenizes line filter operators', () => { | ||
| expect(kinds('|= |~')).toEqual([TokenKind.PipeEquals, TokenKind.PipeRegex]); | ||
| }); | ||
| it('tokenizes bare pipe after != / !~', () => { | ||
| // | != and | !~ are two tokens each | ||
| const t = nonWhitespace(tokenize('| != "err"')).filter((x) => x.kind !== TokenKind.EOF); | ||
| expect(t.map((x) => x.kind)).toEqual([TokenKind.Pipe, TokenKind.NotEquals, TokenKind.String]); | ||
| }); | ||
| // ── Strings ── | ||
| it('tokenizes double-quoted string', () => { | ||
| const t = nonWhitespace(tokenize('"hello"')); | ||
| expect(t[0].kind).toBe(TokenKind.String); | ||
| expect(t[0].value).toBe('"hello"'); | ||
| }); | ||
| it('tokenizes single-quoted string', () => { | ||
| const t = nonWhitespace(tokenize("'hello'")); | ||
| expect(t[0].kind).toBe(TokenKind.String); | ||
| expect(t[0].value).toBe("'hello'"); | ||
| }); | ||
| it('tokenizes backtick string', () => { | ||
| const t = nonWhitespace(tokenize('`hello`')); | ||
| expect(t[0].kind).toBe(TokenKind.String); | ||
| expect(t[0].value).toBe('`hello`'); | ||
| }); | ||
| it('handles unclosed string', () => { | ||
| const t = nonWhitespace(tokenize('"unclosed')); | ||
| expect(t[0].kind).toBe(TokenKind.String); | ||
| expect(t[0].value).toBe('"unclosed'); | ||
| }); | ||
| // ── Identifiers ── | ||
| it('tokenizes identifiers', () => { | ||
| expect(kinds('job')).toEqual([TokenKind.Ident]); | ||
| expect(values('status')).toEqual(['status']); | ||
| }); | ||
| // ── Duration ── | ||
| it('tokenizes duration values', () => { | ||
| expect(kinds('5m')).toEqual([TokenKind.Duration]); | ||
| expect(values('5m')).toEqual(['5m']); | ||
| expect(values('1h30m')).toEqual(['1h', '30m']); | ||
| expect(values('300ms')).toEqual(['300ms']); | ||
| }); | ||
| // ── Bytes ── | ||
| it('tokenizes bytes values', () => { | ||
| expect(kinds('42MB')).toEqual([TokenKind.Bytes]); | ||
| expect(values('42MB')).toEqual(['42MB']); | ||
| expect(values('1.5KiB')).toEqual(['1.5KiB']); | ||
| }); | ||
| // ── Numbers ── | ||
| it('tokenizes numbers', () => { | ||
| expect(kinds('123')).toEqual([TokenKind.Number]); | ||
| expect(values('45.6')).toEqual(['45.6']); | ||
| }); | ||
| // ── Comments ── | ||
| it('tokenizes # comments', () => { | ||
| const t = nonWhitespace(tokenize('# this is a comment\n{job="nginx"}')).filter((x) => x.kind !== TokenKind.EOF); | ||
| // Comment is filtered out by nonWhitespace | ||
| expect(t.map((x) => x.kind)).toEqual([TokenKind.LCurly, TokenKind.Ident, TokenKind.Equals, TokenKind.String, TokenKind.RCurly]); | ||
| }); | ||
| // ── Full LogQL query ── | ||
| it('tokenizes a full log query', () => { | ||
| const t = nonWhitespace(tokenize('{job="nginx"} |= "error" | json | status >= 400')).filter((x) => x.kind !== TokenKind.EOF); | ||
| expect(t.map((x) => x.kind)).toEqual([ | ||
| TokenKind.LCurly, | ||
| TokenKind.Ident, | ||
| TokenKind.Equals, | ||
| TokenKind.String, | ||
| TokenKind.RCurly, | ||
| TokenKind.PipeEquals, | ||
| TokenKind.String, | ||
| TokenKind.Pipe, | ||
| TokenKind.Ident, | ||
| TokenKind.Pipe, | ||
| TokenKind.Ident, | ||
| TokenKind.GreaterEquals, | ||
| TokenKind.Number, | ||
| ]); | ||
| }); | ||
| it('tokenizes a metric query', () => { | ||
| const t = nonWhitespace(tokenize('rate({job="nginx"}[5m])')); | ||
| const kindsArr = t.map((x) => x.kind); | ||
| expect(kindsArr).toContain(TokenKind.Ident); // rate | ||
| expect(kindsArr).toContain(TokenKind.LParen); | ||
| expect(kindsArr).toContain(TokenKind.LCurly); | ||
| expect(kindsArr).toContain(TokenKind.LBracket); | ||
| expect(kindsArr).toContain(TokenKind.Duration); | ||
| expect(kindsArr).toContain(TokenKind.RBracket); | ||
| expect(kindsArr).toContain(TokenKind.RParen); | ||
| }); | ||
| it('tokenizes multiple matchers', () => { | ||
| const t = nonWhitespace(tokenize('{job="nginx", app="api"}')).filter((x) => x.kind !== TokenKind.EOF); | ||
| expect(t.map((x) => x.kind)).toEqual([ | ||
| TokenKind.LCurly, | ||
| TokenKind.Ident, | ||
| TokenKind.Equals, | ||
| TokenKind.String, | ||
| TokenKind.Comma, | ||
| TokenKind.Ident, | ||
| TokenKind.Equals, | ||
| TokenKind.String, | ||
| TokenKind.RCurly, | ||
| ]); | ||
| }); | ||
| }); |
| import { tokenize, nonWhitespace } from '../parser/lexer'; | ||
| import { parse } from '../parser/parser'; | ||
| import { locate } from '../parser/locate'; | ||
| import type { Situation } from '../parser/types'; | ||
| // ─── Helper ─────────────────────────────────────────────────────── | ||
| function situationAt(input: string, offset: number): Situation { | ||
| const rawTokens = tokenize(input); | ||
| const tokens = nonWhitespace(rawTokens); | ||
| const ast = parse(input); | ||
| return locate(offset, tokens); | ||
| } | ||
| describe('Loki locate', () => { | ||
| // ── IN_SELECTOR_LABEL ── | ||
| it('returns IN_SELECTOR_LABEL for empty {}', () => { | ||
| const s = situationAt('{}', 1); | ||
| expect(s.kind).toBe('IN_SELECTOR_LABEL'); | ||
| }); | ||
| it('returns IN_SELECTOR_LABEL after opening brace', () => { | ||
| const s = situationAt('{', 1); | ||
| expect(s.kind).toBe('IN_SELECTOR_LABEL'); | ||
| }); | ||
| it('returns IN_SELECTOR_LABEL after comma in multi-matcher', () => { | ||
| const s = situationAt('{job="nginx", }', 13); | ||
| expect(s.kind).toBe('IN_SELECTOR_LABEL'); | ||
| }); | ||
| it('returns IN_SELECTOR_LABEL when typing label name', () => { | ||
| const s = situationAt('{j', 2); | ||
| expect(s.kind).toBe('IN_SELECTOR_LABEL'); | ||
| }); | ||
| // ── IN_SELECTOR_VALUE ── | ||
| it('returns IN_SELECTOR_VALUE after operator', () => { | ||
| const s = situationAt('{job=', 5); | ||
| expect(s.kind).toBe('IN_SELECTOR_VALUE'); | ||
| expect(s.labelName).toBe('job'); | ||
| }); | ||
| it('returns IN_SELECTOR_VALUE after != operator', () => { | ||
| const s = situationAt('{job!=', 6); | ||
| expect(s.kind).toBe('IN_SELECTOR_VALUE'); | ||
| expect(s.labelName).toBe('job'); | ||
| }); | ||
| it('returns IN_SELECTOR_VALUE inside string value', () => { | ||
| const s = situationAt('{job="', 6); | ||
| expect(s.kind).toBe('IN_SELECTOR_VALUE'); | ||
| expect(s.labelName).toBe('job'); | ||
| expect(s.insideString).toBe(true); | ||
| }); | ||
| it('returns IN_SELECTOR_VALUE between quotes', () => { | ||
| const s = situationAt('{job=""}', 6); | ||
| expect(s.kind).toBe('IN_SELECTOR_VALUE'); | ||
| expect(s.labelName).toBe('job'); | ||
| expect(s.insideString).toBe(true); | ||
| }); | ||
| it('returns IN_SELECTOR_VALUE for =~ operator', () => { | ||
| const s = situationAt('{job=~', 6); | ||
| expect(s.kind).toBe('IN_SELECTOR_VALUE'); | ||
| expect(s.labelName).toBe('job'); | ||
| }); | ||
| // ── AFTER_PIPE ── | ||
| it('returns AFTER_PIPE after pipe', () => { | ||
| const s = situationAt('{job="nginx"} | ', 16); | ||
| expect(s.kind).toBe('AFTER_PIPE'); | ||
| }); | ||
| it('returns IN_SELECTOR_LABEL inside {}', () => { | ||
| const s = situationAt('{}', 1); | ||
| expect(s.kind).toBe('IN_SELECTOR_LABEL'); | ||
| }); | ||
| // ── IN_FUNCTION_PARENS ── | ||
| it('returns IN_FUNCTION_PARENS inside rate()', () => { | ||
| const s = situationAt('rate(', 5); | ||
| expect(s.kind).toBe('IN_FUNCTION_PARENS'); | ||
| }); | ||
| it('returns IN_FUNCTION_PARENS inside count_over_time()', () => { | ||
| const s = situationAt('count_over_time(', 16); | ||
| expect(s.kind).toBe('IN_FUNCTION_PARENS'); | ||
| }); | ||
| it('returns IN_FUNCTION_PARENS at root level', () => { | ||
| const s = situationAt('', 0); | ||
| expect(s.kind).toBe('IN_FUNCTION_PARENS'); | ||
| }); | ||
| // ── NONE inside range brackets ── | ||
| it('returns NONE inside [5m]', () => { | ||
| const s = situationAt('{job="nginx"}[5m]', 15); | ||
| expect(s.kind).not.toBe('IN_FUNCTION_PARENS'); | ||
| }); | ||
| it('returns NONE inside []', () => { | ||
| const s = situationAt('rate({job="nginx"}[', 19); | ||
| expect(s.kind).not.toBe('IN_FUNCTION_PARENS'); | ||
| }); | ||
| it('returns NONE typing range inside brackets', () => { | ||
| const s = situationAt('rate({job="nginx"}[1m', 21); | ||
| expect(s.kind).not.toBe('IN_FUNCTION_PARENS'); | ||
| }); | ||
| // ── matcherIndex ── | ||
| it('sets matcherIndex for first matcher', () => { | ||
| const s = situationAt('{job=', 5); | ||
| expect(s.matcherIndex).toBe(0); | ||
| }); | ||
| it('sets matcherIndex for second matcher', () => { | ||
| const s = situationAt('{job="nginx", app=', 18); | ||
| expect(s.matcherIndex).toBe(1); | ||
| }); | ||
| it('sets matcherIndex inside string of second matcher', () => { | ||
| const s = situationAt('{job="nginx", app="', 19); | ||
| expect(s.matcherIndex).toBe(1); | ||
| }); | ||
| // ── Prefix ── | ||
| it('uses full identifier as prefix at end of word', () => { | ||
| const s = situationAt('count', 5); | ||
| expect(s.prefix).toBe('count'); | ||
| }); | ||
| it('uses partial identifier as prefix', () => { | ||
| const s = situationAt('cou', 3); | ||
| expect(s.prefix).toBe('cou'); | ||
| }); | ||
| // ── Label filter ── | ||
| it('returns IN_LABEL_FILTER after label value comparison', () => { | ||
| const s = situationAt('{job="nginx"} | status >', 24); | ||
| expect(s.kind).toBe('IN_LABEL_FILTER'); | ||
| }); | ||
| }); |
| import { parse } from '../parser/parser'; | ||
| import type { LokiQuery, StreamSelector, LineFilter, LabelFilter, MetricExpr, JsonParser, LogfmtParser, RegexpParser } from '../parser/types'; | ||
| // ─── Helper ─────────────────────────────────────────────────────── | ||
| function findStage<S extends { type: string }>(query: LokiQuery, type: string): S | undefined { | ||
| return query.pipeline.find((s) => s.type === type) as S | undefined; | ||
| } | ||
| describe('Loki parser', () => { | ||
| // ── Stream selector ── | ||
| it('parses empty stream selector', () => { | ||
| const result = parse('{}'); | ||
| expect(result.streamSelector).not.toBeNull(); | ||
| expect(result.streamSelector!.matchers).toEqual([]); | ||
| expect(result.errors.length).toBe(0); | ||
| }); | ||
| it('parses single matcher', () => { | ||
| const result = parse('{job="nginx"}'); | ||
| expect(result.streamSelector!.matchers).toHaveLength(1); | ||
| expect(result.streamSelector!.matchers[0]).toEqual({ | ||
| label: 'job', | ||
| operator: '=', | ||
| value: '"nginx"', | ||
| }); | ||
| }); | ||
| it('parses multiple matchers', () => { | ||
| const result = parse('{job="nginx", app="api"}'); | ||
| expect(result.streamSelector!.matchers).toHaveLength(2); | ||
| expect(result.streamSelector!.matchers[0].label).toBe('job'); | ||
| expect(result.streamSelector!.matchers[1].label).toBe('app'); | ||
| }); | ||
| it('parses != and =~ and !~ operators', () => { | ||
| const r1 = parse('{job!="nginx"}'); | ||
| expect(r1.streamSelector!.matchers[0].operator).toBe('!='); | ||
| const r2 = parse('{job=~"nginx|api"}'); | ||
| expect(r2.streamSelector!.matchers[0].operator).toBe('=~'); | ||
| const r3 = parse('{job!~"nginx"}'); | ||
| expect(r3.streamSelector!.matchers[0].operator).toBe('!~'); | ||
| }); | ||
| // ── Line filters ── | ||
| it('parses line filter |=', () => { | ||
| const result = parse('{job="nginx"} |= "error"'); | ||
| const stage = findStage<LineFilter>(result, 'lineFilter'); | ||
| expect(stage).toBeDefined(); | ||
| expect(stage!.operator).toBe('|='); | ||
| expect(stage!.value).toBe('"error"'); | ||
| }); | ||
| it('parses line filter !=', () => { | ||
| const result = parse('{job="nginx"} | != "error"'); | ||
| const stage = findStage<LineFilter>(result, 'lineFilter'); | ||
| expect(stage).toBeDefined(); | ||
| expect(stage!.operator).toBe('!='); | ||
| }); | ||
| it('parses line filter !~', () => { | ||
| const result = parse('{job="nginx"} | !~ "5[0-9]+"'); | ||
| const stage = findStage<LineFilter>(result, 'lineFilter'); | ||
| expect(stage).toBeDefined(); | ||
| expect(stage!.operator).toBe('!~'); | ||
| }); | ||
| // ── Parsers ── | ||
| it('parses | json', () => { | ||
| const result = parse('{job="nginx"} | json'); | ||
| const stage = findStage<JsonParser>(result, 'jsonParser'); | ||
| expect(stage).toBeDefined(); | ||
| }); | ||
| it('parses | logfmt', () => { | ||
| const result = parse('{job="nginx"} | logfmt'); | ||
| const stage = findStage<LogfmtParser>(result, 'logfmtParser'); | ||
| expect(stage).toBeDefined(); | ||
| }); | ||
| it('parses | regexp', () => { | ||
| const result = parse('{job="nginx"} | regexp "(?P<method>\\\\w+)"'); | ||
| const stage = findStage<RegexpParser>(result, 'regexpParser'); | ||
| expect(stage).toBeDefined(); | ||
| expect(stage!.pattern).toBe('"(?P<method>\\\\w+)"'); | ||
| }); | ||
| it('parses | pattern', () => { | ||
| const result = parse('{job="nginx"} | pattern "<method>"'); | ||
| const stage = findStage<RegexpParser>(result, 'regexpParser'); | ||
| expect(stage).toBeDefined(); | ||
| }); | ||
| it('parses | unpack', () => { | ||
| const result = parse('{job="nginx"} | unpack'); | ||
| const stage = findStage<JsonParser>(result, 'jsonParser'); | ||
| expect(stage).toBeDefined(); | ||
| }); | ||
| // ── Label filters ── | ||
| it('parses label filter with >=', () => { | ||
| const result = parse('{job="nginx"} | status >= 400'); | ||
| const stage = findStage<LabelFilter>(result, 'labelFilter'); | ||
| expect(stage).toBeDefined(); | ||
| expect(stage!.conditions).toHaveLength(1); | ||
| expect(stage!.conditions[0]).toEqual({ | ||
| label: 'status', | ||
| operator: '>=', | ||
| value: '400', | ||
| }); | ||
| }); | ||
| it('parses label filter with string equals', () => { | ||
| const result = parse('{job="nginx"} | level = "error"'); | ||
| const stage = findStage<LabelFilter>(result, 'labelFilter'); | ||
| expect(stage).toBeDefined(); | ||
| expect(stage!.conditions[0].operator).toBe('='); | ||
| expect(stage!.conditions[0].value).toBe('"error"'); | ||
| }); | ||
| it('parses label filter with and', () => { | ||
| const result = parse('{job="nginx"} | status >= 400 and level = "error"'); | ||
| const stage = findStage<LabelFilter>(result, 'labelFilter'); | ||
| expect(stage!.conditions).toHaveLength(2); | ||
| }); | ||
| // ── Metric expressions ── | ||
| it('parses rate(...[5m])', () => { | ||
| const result = parse('rate({job="nginx"}[5m])'); | ||
| expect(result.metricExpr).not.toBeNull(); | ||
| expect(result.metricExpr!.func).toBe('rate'); | ||
| expect(result.metricExpr!.range).toBe('5m'); | ||
| }); | ||
| it('parses count_over_time', () => { | ||
| const result = parse('count_over_time({job="api"}[1h])'); | ||
| expect(result.metricExpr!.func).toBe('count_over_time'); | ||
| expect(result.metricExpr!.range).toBe('1h'); | ||
| }); | ||
| it('parses sum', () => { | ||
| const result = parse('sum(rate({job="nginx"}[5m]))'); | ||
| expect(result.metricExpr).not.toBeNull(); | ||
| expect(result.metricExpr!.func).toBe('sum'); | ||
| }); | ||
| // ── Multiple pipeline stages ── | ||
| it('parses multiple pipeline stages', () => { | ||
| const result = parse('{job="nginx"} |= "error" | json | status >= 400'); | ||
| expect(result.pipeline).toHaveLength(3); | ||
| expect(result.pipeline[0].type).toBe('lineFilter'); | ||
| expect(result.pipeline[1].type).toBe('jsonParser'); | ||
| expect(result.pipeline[2].type).toBe('labelFilter'); | ||
| }); | ||
| // ── Partial / error recovery ── | ||
| it('recovers from incomplete matcher', () => { | ||
| const result = parse('{job}'); | ||
| // Should have an incomplete matcher but not crash | ||
| expect(result.streamSelector).not.toBeNull(); | ||
| }); | ||
| it('recovers from empty input', () => { | ||
| const result = parse(''); | ||
| expect(result.streamSelector).toBeNull(); | ||
| expect(result.pipeline).toEqual([]); | ||
| expect(result.metricExpr).toBeNull(); | ||
| }); | ||
| }); |
| import * as monaco from 'monaco-editor'; | ||
| import type { Situation } from '../parser/types'; | ||
| import type { LabelMatcher } from '../types'; | ||
| export interface BuildCompletionsArgs { | ||
| situation: Situation; | ||
| range: monaco.IRange; | ||
| variables: string[]; | ||
| currentMatchers: LabelMatcher[]; | ||
| fetchLabelNames?: (currentMatchers: LabelMatcher[]) => Promise<string[]>; | ||
| fetchLabelValues?: (labelName: string, currentMatchers: LabelMatcher[]) => Promise<string[]>; | ||
| } | ||
| const SORT = { | ||
| variable: '0', | ||
| labelValue: '1', | ||
| labelName: '2', | ||
| keyword: '4', | ||
| function: '5', | ||
| }; | ||
| // ─── Line filter operators ──────────────────────────────────────── | ||
| const LINE_FILTER_OPERATORS = [ | ||
| { label: '|=', detail: 'Line contains', insertText: '|= ', kind: monaco.languages.CompletionItemKind.Operator }, | ||
| { label: '!=', detail: 'Line does not contain', insertText: '!= ', kind: monaco.languages.CompletionItemKind.Operator }, | ||
| { label: '|~', detail: 'Line matches regex', insertText: '|~ ', kind: monaco.languages.CompletionItemKind.Operator }, | ||
| { label: '!~', detail: 'Line does not match regex', insertText: '!~ ', kind: monaco.languages.CompletionItemKind.Operator }, | ||
| ]; | ||
| // ─── Pipeline stage keywords ────────────────────────────────────── | ||
| const PIPELINE_STAGES = [ | ||
| { label: 'json', detail: 'Parse JSON from log line', insertText: 'json', kind: monaco.languages.CompletionItemKind.Keyword }, | ||
| { label: 'logfmt', detail: 'Parse logfmt-encoded log line', insertText: 'logfmt', kind: monaco.languages.CompletionItemKind.Keyword }, | ||
| { label: 'regexp', detail: 'Parse using regex pattern', insertText: 'regexp ', kind: monaco.languages.CompletionItemKind.Keyword }, | ||
| { label: 'pattern', detail: 'Parse using log pattern', insertText: 'pattern ', kind: monaco.languages.CompletionItemKind.Keyword }, | ||
| { label: 'unpack', detail: 'Unpack packed log line', insertText: 'unpack', kind: monaco.languages.CompletionItemKind.Keyword }, | ||
| { label: 'line_format', detail: 'Format log line content', insertText: 'line_format ', kind: monaco.languages.CompletionItemKind.Keyword }, | ||
| { label: 'label_format', detail: 'Rename or format labels', insertText: 'label_format ', kind: monaco.languages.CompletionItemKind.Keyword }, | ||
| { label: 'drop', detail: 'Drop labels by name or value', insertText: 'drop ', kind: monaco.languages.CompletionItemKind.Keyword }, | ||
| { label: 'keep', detail: 'Keep only specified labels', insertText: 'keep ', kind: monaco.languages.CompletionItemKind.Keyword }, | ||
| { label: 'unwrap', detail: 'Extract a label as sample data', insertText: 'unwrap ', kind: monaco.languages.CompletionItemKind.Keyword }, | ||
| { label: 'decolorize', detail: 'Remove ANSI color codes', insertText: 'decolorize', kind: monaco.languages.CompletionItemKind.Keyword }, | ||
| ]; | ||
| // ─── Metric functions ───────────────────────────────────────────── | ||
| const METRIC_FUNCTIONS = [ | ||
| { label: 'rate', detail: 'Per-second rate of log lines', insertText: 'rate', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'rate_counter', detail: 'Rate as counter metric', insertText: 'rate_counter', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { | ||
| label: 'count_over_time', | ||
| detail: 'Count log lines over time', | ||
| insertText: 'count_over_time', | ||
| kind: monaco.languages.CompletionItemKind.Function, | ||
| }, | ||
| { label: 'bytes_rate', detail: 'Bytes per second', insertText: 'bytes_rate', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'bytes_over_time', detail: 'Bytes over time', insertText: 'bytes_over_time', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'sum_over_time', detail: 'Sum over time', insertText: 'sum_over_time', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'avg_over_time', detail: 'Average over time', insertText: 'avg_over_time', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'max_over_time', detail: 'Maximum over time', insertText: 'max_over_time', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'min_over_time', detail: 'Minimum over time', insertText: 'min_over_time', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'first_over_time', detail: 'First value over time', insertText: 'first_over_time', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'last_over_time', detail: 'Last value over time', insertText: 'last_over_time', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'stdvar_over_time', detail: 'Variance over time', insertText: 'stdvar_over_time', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'stddev_over_time', detail: 'Std dev over time', insertText: 'stddev_over_time', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { | ||
| label: 'quantile_over_time', | ||
| detail: 'Quantile over time', | ||
| insertText: 'quantile_over_time', | ||
| kind: monaco.languages.CompletionItemKind.Function, | ||
| }, | ||
| { label: 'absent_over_time', detail: '1 if no data', insertText: 'absent_over_time', kind: monaco.languages.CompletionItemKind.Function }, | ||
| ]; | ||
| // ─── Aggregation functions ──────────────────────────────────────── | ||
| const AGGREGATION_FUNCTIONS = [ | ||
| { label: 'sum', insertText: 'sum', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'avg', insertText: 'avg', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'min', insertText: 'min', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'max', insertText: 'max', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'count', insertText: 'count', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'stddev', insertText: 'stddev', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'stdvar', insertText: 'stdvar', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'topk', insertText: 'topk', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'bottomk', insertText: 'bottomk', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'sort', insertText: 'sort', kind: monaco.languages.CompletionItemKind.Function }, | ||
| { label: 'sort_desc', insertText: 'sort_desc', kind: monaco.languages.CompletionItemKind.Function }, | ||
| ]; | ||
| // ─── Helpers ────────────────────────────────────────────────────── | ||
| function variableItems(variables: string[], range: monaco.IRange, wrapInQuotes?: boolean): monaco.languages.CompletionItem[] { | ||
| return variables.map((v) => ({ | ||
| label: `\${${v}}`, | ||
| kind: monaco.languages.CompletionItemKind.Variable, | ||
| insertText: wrapInQuotes ? `"\${${v}}"` : `\${${v}}`, | ||
| range, | ||
| sortText: SORT.variable, | ||
| })); | ||
| } | ||
| function keywordItems( | ||
| items: Array<{ label: string; detail?: string; insertText: string; kind: monaco.languages.CompletionItemKind }>, | ||
| range: monaco.IRange, | ||
| ): monaco.languages.CompletionItem[] { | ||
| return items.map((item) => ({ | ||
| label: item.label, | ||
| kind: item.kind, | ||
| detail: item.detail, | ||
| insertText: item.insertText, | ||
| range, | ||
| sortText: SORT.keyword, | ||
| })); | ||
| } | ||
| function functionItems(items: Array<{ label: string; insertText: string; kind: monaco.languages.CompletionItemKind }>, range: monaco.IRange): monaco.languages.CompletionItem[] { | ||
| return items.map((item) => ({ | ||
| label: item.label, | ||
| kind: item.kind, | ||
| insertText: item.insertText, | ||
| range, | ||
| sortText: SORT.function, | ||
| })); | ||
| } | ||
| // ─── Build completions ──────────────────────────────────────────── | ||
| export async function buildCompletions(args: BuildCompletionsArgs): Promise<monaco.languages.CompletionItem[]> { | ||
| const { situation, range, variables } = args; | ||
| const items: monaco.languages.CompletionItem[] = []; | ||
| switch (situation.kind) { | ||
| case 'NONE': | ||
| break; | ||
| case 'IN_SELECTOR_LABEL': { | ||
| // Offer async label names only — no variables (they belong in value context) | ||
| if (args.fetchLabelNames) { | ||
| try { | ||
| const names = await args.fetchLabelNames(args.currentMatchers); | ||
| for (const name of names) { | ||
| items.push({ | ||
| label: name, | ||
| kind: monaco.languages.CompletionItemKind.Property, | ||
| insertText: name, | ||
| range, | ||
| sortText: SORT.labelName, | ||
| }); | ||
| } | ||
| } catch { | ||
| // Silently ignore fetch errors | ||
| } | ||
| } | ||
| break; | ||
| } | ||
| case 'IN_SELECTOR_VALUE': { | ||
| // Offer variables (quoted when outside a string, raw when inside) + async label values | ||
| items.push(...variableItems(variables, range, !situation.insideString)); | ||
| if (args.fetchLabelValues && situation.labelName) { | ||
| try { | ||
| const values = await args.fetchLabelValues(situation.labelName, args.currentMatchers); | ||
| for (const v of values) { | ||
| items.push({ | ||
| label: v, | ||
| kind: monaco.languages.CompletionItemKind.Value, | ||
| // When inside a string the quotes are already in the editor, | ||
| // so insert the raw value. Otherwise wrap in quotes. | ||
| insertText: situation.insideString ? v : `"${v}"`, | ||
| range, | ||
| sortText: SORT.labelValue, | ||
| }); | ||
| } | ||
| } catch { | ||
| // ignore | ||
| } | ||
| } | ||
| break; | ||
| } | ||
| case 'AFTER_PIPE': { | ||
| // Offer pipeline stages and line filter operators (no variables) | ||
| items.push(...keywordItems(LINE_FILTER_OPERATORS, range)); | ||
| items.push(...keywordItems(PIPELINE_STAGES, range)); | ||
| break; | ||
| } | ||
| case 'IN_PIPELINE_ARG': { | ||
| break; | ||
| } | ||
| case 'IN_LABEL_FILTER': { | ||
| items.push( | ||
| ...keywordItems( | ||
| [ | ||
| { label: '>=', detail: 'Greater or equal', insertText: '>= ', kind: monaco.languages.CompletionItemKind.Operator }, | ||
| { label: '<=', detail: 'Less or equal', insertText: '<= ', kind: monaco.languages.CompletionItemKind.Operator }, | ||
| { label: '>', detail: 'Greater than', insertText: '> ', kind: monaco.languages.CompletionItemKind.Operator }, | ||
| { label: '<', detail: 'Less than', insertText: '< ', kind: monaco.languages.CompletionItemKind.Operator }, | ||
| { label: '=', detail: 'Equals', insertText: '= ', kind: monaco.languages.CompletionItemKind.Operator }, | ||
| { label: '!=', detail: 'Not equals', insertText: '!= ', kind: monaco.languages.CompletionItemKind.Operator }, | ||
| { label: '=~', detail: 'Regex matches', insertText: '=~ ', kind: monaco.languages.CompletionItemKind.Operator }, | ||
| { label: '!~', detail: 'Regex not matches', insertText: '!~ ', kind: monaco.languages.CompletionItemKind.Operator }, | ||
| ], | ||
| range, | ||
| ), | ||
| ); | ||
| break; | ||
| } | ||
| case 'IN_FUNCTION_PARENS': { | ||
| // Offer metric / aggregation functions (no variables, no stream selector snippet) | ||
| items.push(...functionItems(METRIC_FUNCTIONS, range)); | ||
| items.push(...functionItems(AGGREGATION_FUNCTIONS, range)); | ||
| break; | ||
| } | ||
| case 'IN_GROUPING': { | ||
| if (args.fetchLabelNames) { | ||
| try { | ||
| const names = await args.fetchLabelNames(args.currentMatchers); | ||
| for (const name of names) { | ||
| items.push({ | ||
| label: name, | ||
| kind: monaco.languages.CompletionItemKind.Property, | ||
| insertText: name, | ||
| range, | ||
| sortText: SORT.labelName, | ||
| }); | ||
| } | ||
| } catch { | ||
| // ignore | ||
| } | ||
| } | ||
| break; | ||
| } | ||
| } | ||
| return items; | ||
| } |
| import * as monaco from 'monaco-editor'; | ||
| import { parseAndLocate } from '../parser/locate'; | ||
| import { offsetToPosition } from '../parser/lexer'; | ||
| import { buildCompletions } from './completions'; | ||
| import type { LabelMatcher } from '../types'; | ||
| import type { MutableRefObject } from 'react'; | ||
| export interface ProviderOptions { | ||
| fetchLabelNamesRef: MutableRefObject<((currentMatchers: LabelMatcher[]) => Promise<string[]>) | undefined>; | ||
| fetchLabelValuesRef: MutableRefObject<((labelName: string, currentMatchers: LabelMatcher[]) => Promise<string[]>) | undefined>; | ||
| variablesRef: MutableRefObject<string[]>; | ||
| } | ||
| export function getLokiCompletionProvider(opts: ProviderOptions): monaco.languages.CompletionItemProvider { | ||
| return { | ||
| triggerCharacters: ['{', '|', '=', '~', ',', '(', ' ', '$', '"', "'"], | ||
| provideCompletionItems: async (model, position) => { | ||
| const value = model.getValue(); | ||
| const offset = model.getOffsetAt(position); | ||
| // Parse and locate | ||
| const { ast, situation } = parseAndLocate(value, offset); | ||
| // Extract current matchers from the AST stream selector for context, | ||
| // excluding the matcher currently being edited (identified by matcherIndex). | ||
| let currentMatchers: LabelMatcher[] = | ||
| ast.streamSelector?.matchers.map((m) => ({ | ||
| label: m.label, | ||
| operator: m.operator, | ||
| value: m.value, | ||
| })) ?? []; | ||
| if (situation.matcherIndex !== undefined) { | ||
| currentMatchers = currentMatchers.filter((_, i) => i !== situation.matcherIndex); | ||
| } | ||
| // Build the Monaco range from the situation's replaceRange | ||
| const startPos = offsetToPosition(value, situation.replaceRange.start); | ||
| const endPos = position; // current cursor position | ||
| const range = { | ||
| startLineNumber: startPos.lineNumber, | ||
| startColumn: startPos.column, | ||
| endLineNumber: endPos.lineNumber, | ||
| endColumn: endPos.column, | ||
| }; | ||
| const suggestions = await buildCompletions({ | ||
| situation, | ||
| range, | ||
| variables: opts.variablesRef.current, | ||
| currentMatchers, | ||
| fetchLabelNames: opts.fetchLabelNamesRef.current, | ||
| fetchLabelValues: opts.fetchLabelValuesRef.current, | ||
| }); | ||
| return { suggestions }; | ||
| }, | ||
| }; | ||
| } |
| import React, { useEffect, useRef } from 'react'; | ||
| import MonacoEditor from 'react-monaco-editor'; | ||
| import * as monaco from 'monaco-editor'; | ||
| import type * as monacoTypes from 'monaco-editor/esm/vs/editor/editor.api'; | ||
| import { v4 as uuidv4 } from 'uuid'; | ||
| import { css } from '@emotion/css'; | ||
| import { languageConfiguration } from './loki'; | ||
| import { buildMonarchLanguage } from './monarch/buildLanguage'; | ||
| import { getLokiCompletionProvider } from './completion/getCompletionProvider'; | ||
| import type { LokiEditorProps } from './types'; | ||
| export type { LokiEditorProps, LabelMatcher } from './types'; | ||
| const LANG_ID = 'loki'; | ||
| const SIZE_MAP: Record<string, { className: string; minHeight: number }> = { | ||
| small: { className: 'ant-input-sm', minHeight: 24 }, | ||
| middle: { className: 'ant-input-md', minHeight: 32 }, | ||
| large: { className: 'ant-input-lg', minHeight: 40 }, | ||
| }; | ||
| const themeMap: Record<string, string> = { | ||
| light: 'loki-light', | ||
| dark: 'loki-dark', | ||
| }; | ||
| const containerDisabledClassName = css` | ||
| .monaco-editor { | ||
| user-select: none; | ||
| pointer-events: none; | ||
| } | ||
| `; | ||
| const containerReadOnlyClassName = css` | ||
| .monaco-editor .cursors-layer > .cursor { | ||
| opacity: 0 !important; | ||
| } | ||
| `; | ||
| let languageRegistered = false; | ||
| function ensureLanguage() { | ||
| if (languageRegistered) return; | ||
| if (!monaco.languages.getLanguages().some((lang) => lang.id === LANG_ID)) { | ||
| monaco.languages.register({ id: LANG_ID }); | ||
| monaco.languages.setLanguageConfiguration(LANG_ID, languageConfiguration); | ||
| monaco.languages.setMonarchTokensProvider(LANG_ID, buildMonarchLanguage()); | ||
| } | ||
| languageRegistered = true; | ||
| } | ||
| let themesEnsured = false; | ||
| function ensureThemes() { | ||
| if (themesEnsured) return; | ||
| themesEnsured = true; | ||
| monaco.editor.defineTheme('loki-light', { | ||
| base: 'vs', | ||
| inherit: true, | ||
| rules: [ | ||
| { token: 'keyword.pipeline', foreground: '795e26' }, | ||
| { token: 'keyword.function', foreground: '795e26', fontStyle: 'bold' }, | ||
| { token: 'keyword.aggregation', foreground: '0070c1' }, | ||
| { token: 'keyword', foreground: '0070c1' }, | ||
| { token: 'operator.pipe', foreground: 'b8860b', fontStyle: 'bold' }, | ||
| { token: 'operator', foreground: 'b8860b' }, | ||
| { token: 'type.duration', foreground: '098658' }, | ||
| { token: 'type.bytes', foreground: '098658' }, | ||
| { token: 'string', foreground: 'a31515' }, | ||
| { token: 'number', foreground: '098658' }, | ||
| { token: 'number.float', foreground: '098658' }, | ||
| { token: 'comment', foreground: '6a9955', fontStyle: 'italic' }, | ||
| { token: 'delimiter.curly', foreground: '0070c1' }, | ||
| ], | ||
| colors: { | ||
| 'editor.background': '#00000000', | ||
| focusBorder: '#00000000', | ||
| }, | ||
| }); | ||
| monaco.editor.defineTheme('loki-dark', { | ||
| base: 'vs-dark', | ||
| inherit: true, | ||
| rules: [ | ||
| { token: 'keyword.pipeline', foreground: 'dcdcaa' }, | ||
| { token: 'keyword.function', foreground: 'dcdcaa', fontStyle: 'bold' }, | ||
| { token: 'keyword.aggregation', foreground: '9cdcfe' }, | ||
| { token: 'keyword', foreground: '9cdcfe' }, | ||
| { token: 'operator.pipe', foreground: 'ffd700', fontStyle: 'bold' }, | ||
| { token: 'operator', foreground: 'ffd700' }, | ||
| { token: 'type.duration', foreground: '6a9955' }, | ||
| { token: 'type.bytes', foreground: '6a9955' }, | ||
| { token: 'string', foreground: 'ce9178' }, | ||
| { token: 'number', foreground: 'b5cea8' }, | ||
| { token: 'number.float', foreground: 'b5cea8' }, | ||
| { token: 'comment', foreground: '6a9955', fontStyle: 'italic' }, | ||
| { token: 'delimiter.curly', foreground: '9cdcfe' }, | ||
| ], | ||
| colors: { | ||
| 'editor.background': '#00000000', | ||
| focusBorder: '#00000000', | ||
| }, | ||
| }); | ||
| } | ||
| /* ---------- Global keybinding rules registered once ---------- */ | ||
| let globalKeybindingDisposables: monaco.IDisposable[] | null = null; | ||
| function ensureGlobalKeybindings() { | ||
| if (globalKeybindingDisposables) return; | ||
| globalKeybindingDisposables = [ | ||
| monaco.editor.addKeybindingRule({ | ||
| keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, | ||
| command: null, | ||
| }), | ||
| monaco.editor.addKeybindingRule({ | ||
| keybinding: monaco.KeyCode.Enter, | ||
| command: '-', | ||
| when: '!suggestWidgetVisible', | ||
| }), | ||
| ]; | ||
| } | ||
| function disposeGlobalKeybindings() { | ||
| if (!globalKeybindingDisposables) return; | ||
| globalKeybindingDisposables.forEach((d) => d.dispose()); | ||
| globalKeybindingDisposables = null; | ||
| } | ||
| const LokiMonacoEditor: React.FC<LokiEditorProps> = (props) => { | ||
| const id = uuidv4(); | ||
| const { | ||
| className, | ||
| maxHeight, | ||
| fontSize, | ||
| size = 'middle', | ||
| theme = 'light', | ||
| value = '', | ||
| placeholder, | ||
| enableAutocomplete = true, | ||
| readOnly = false, | ||
| disabled = false, | ||
| variables, | ||
| onChange, | ||
| onEnter, | ||
| onBlur, | ||
| onFocus, | ||
| editorDidMount, | ||
| fetchLabelNames, | ||
| fetchLabelValues, | ||
| } = props; | ||
| const containerRef = useRef<HTMLDivElement>(null); | ||
| const editorRef = useRef<monacoTypes.editor.IStandaloneCodeEditor | null>(null); | ||
| const disposablesRef = useRef<monaco.IDisposable[]>([]); | ||
| const variablesRef = useRef<string[]>(variables ?? []); | ||
| const fetchLabelNamesRef = useRef<typeof fetchLabelNames>(fetchLabelNames); | ||
| const fetchLabelValuesRef = useRef<typeof fetchLabelValues>(fetchLabelValues); | ||
| useEffect(() => { | ||
| variablesRef.current = variables ?? []; | ||
| }, [variables]); | ||
| useEffect(() => { | ||
| fetchLabelNamesRef.current = fetchLabelNames; | ||
| }, [fetchLabelNames]); | ||
| useEffect(() => { | ||
| fetchLabelValuesRef.current = fetchLabelValues; | ||
| }, [fetchLabelValues]); | ||
| ensureLanguage(); | ||
| useEffect(() => { | ||
| if (!enableAutocomplete) return; | ||
| const disposable = monaco.languages.registerCompletionItemProvider( | ||
| LANG_ID, | ||
| getLokiCompletionProvider({ | ||
| variablesRef, | ||
| fetchLabelNamesRef, | ||
| fetchLabelValuesRef, | ||
| }), | ||
| ); | ||
| disposablesRef.current.push(disposable); | ||
| return () => { | ||
| disposable.dispose(); | ||
| disposablesRef.current = disposablesRef.current.filter((d) => d !== disposable); | ||
| }; | ||
| }, [enableAutocomplete]); | ||
| useEffect(() => { | ||
| ensureGlobalKeybindings(); | ||
| return () => { | ||
| disposeGlobalKeybindings(); | ||
| disposablesRef.current.forEach((d) => d.dispose()); | ||
| disposablesRef.current = []; | ||
| }; | ||
| }, []); | ||
| const handleEditorMount = (editor: monacoTypes.editor.IStandaloneCodeEditor) => { | ||
| editorRef.current = editor; | ||
| ensureThemes(); | ||
| const isEditorFocused = editor.createContextKey<boolean>('isEditorFocused' + id, false); | ||
| editor.onDidBlurEditorWidget(() => { | ||
| isEditorFocused.set(false); | ||
| onBlur?.(editor.getValue()); | ||
| const position = editor.getPosition(); | ||
| if (position) { | ||
| editor.setSelection(new monaco.Selection(position.lineNumber, position.column, position.lineNumber, position.column)); | ||
| } | ||
| }); | ||
| editor.onDidFocusEditorText(() => { | ||
| isEditorFocused.set(true); | ||
| onFocus?.(editor.getValue()); | ||
| }); | ||
| // Auto-resize height | ||
| const updateElementHeight = () => { | ||
| const containerDiv = containerRef.current; | ||
| if (containerDiv !== null) { | ||
| const pixelHeight = editor.getContentHeight(); | ||
| containerDiv.style.minHeight = `${pixelHeight}px`; | ||
| containerDiv.style.width = '100%'; | ||
| const pixelWidth = containerDiv.clientWidth; | ||
| editor.layout({ width: pixelWidth, height: pixelHeight }); | ||
| } | ||
| }; | ||
| editor.onDidContentSizeChange(updateElementHeight); | ||
| updateElementHeight(); | ||
| // Shift+Enter → newline | ||
| editor.addCommand( | ||
| monaco.KeyMod.Shift | monaco.KeyCode.Enter, | ||
| () => { | ||
| const position = editor.getPosition(); | ||
| if (position) { | ||
| editor.executeEdits('shift-enter', [ | ||
| { | ||
| range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column), | ||
| text: '\n', | ||
| }, | ||
| ]); | ||
| editor.setPosition({ lineNumber: position.lineNumber + 1, column: 1 }); | ||
| } | ||
| }, | ||
| 'isEditorFocused' + id, | ||
| ); | ||
| // Enter → execute query | ||
| editor.addCommand( | ||
| monaco.KeyCode.Enter, | ||
| () => { | ||
| onEnter?.(editor.getValue()); | ||
| }, | ||
| '!suggestWidgetVisible && isEditorFocused' + id, | ||
| ); | ||
| // Trigger suggest when `$` is typed | ||
| const model = editor.getModel(); | ||
| if (model) { | ||
| const changeDisposable = model.onDidChangeContent((e) => { | ||
| const insertedDollar = e.changes.some((c) => c.text === '$'); | ||
| if (insertedDollar) { | ||
| setTimeout(() => editor.trigger('loki', 'editor.action.triggerSuggest', {}), 0); | ||
| } | ||
| }); | ||
| disposablesRef.current.push(changeDisposable); | ||
| } | ||
| editorDidMount?.(editor); | ||
| }; | ||
| const handleChange = (newValue: string) => { | ||
| onChange?.(newValue); | ||
| }; | ||
| const themeValue = themeMap[theme]; | ||
| return ( | ||
| <div | ||
| className={ | ||
| 'ant-input' + | ||
| (size ? ` ${SIZE_MAP[size].className}` : '') + | ||
| (disabled ? ` ant-input-disabled ${containerDisabledClassName}` : '') + | ||
| (readOnly ? ` ${containerReadOnlyClassName}` : '') + | ||
| (className ? ` ${className}` : '') | ||
| } | ||
| style={{ | ||
| display: 'block', | ||
| resize: 'vertical', | ||
| overflow: 'auto', | ||
| position: 'relative', | ||
| minHeight: SIZE_MAP[size].minHeight, | ||
| maxHeight, | ||
| }} | ||
| > | ||
| <div ref={containerRef} style={{ height: '100%' }}> | ||
| <MonacoEditor | ||
| width='100%' | ||
| height='100%' | ||
| language={LANG_ID} | ||
| theme={themeValue} | ||
| value={value} | ||
| onChange={handleChange} | ||
| editorDidMount={handleEditorMount} | ||
| options={{ | ||
| minimap: { enabled: false }, | ||
| autoClosingBrackets: 'always', | ||
| autoClosingQuotes: 'always', | ||
| autoIndent: 'full', | ||
| readOnly: readOnly || disabled, | ||
| scrollBeyondLastLine: false, | ||
| smoothScrolling: true, | ||
| fontSize, | ||
| tabSize: 2, | ||
| wordWrap: 'on', | ||
| automaticLayout: true, | ||
| fixedOverflowWidgets: true, | ||
| glyphMargin: false, | ||
| lineNumbers: 'off', | ||
| lineNumbersMinChars: 0, | ||
| folding: false, | ||
| lineDecorationsWidth: 0, | ||
| overviewRulerBorder: false, | ||
| overviewRulerLanes: 0, | ||
| placeholder: placeholder, | ||
| renderLineHighlight: 'none', | ||
| occurrencesHighlight: 'off', | ||
| scrollbar: { | ||
| vertical: 'hidden', | ||
| horizontal: 'hidden', | ||
| alwaysConsumeMouseWheel: false, | ||
| }, | ||
| suggest: { | ||
| showWords: false, | ||
| filterGraceful: false, | ||
| snippetsPreventQuickSuggestions: false, | ||
| shareSuggestSelections: false, | ||
| }, | ||
| }} | ||
| /> | ||
| </div> | ||
| </div> | ||
| ); | ||
| }; | ||
| export default LokiMonacoEditor; | ||
| // Re-export parser and locator for advanced consumers | ||
| export { parse, locate } from './parser'; |
| import type { languages } from 'monaco-editor'; | ||
| export const languageConfiguration: languages.LanguageConfiguration = { | ||
| wordPattern: /(-?\d*\.\d\w*)|([^`~!#%^&*()+\[\]{}|\\;:',.<>\/?\s]+)/g, | ||
| comments: { | ||
| lineComment: '#', | ||
| }, | ||
| brackets: [ | ||
| ['{', '}'], | ||
| ['[', ']'], | ||
| ['(', ')'], | ||
| ], | ||
| autoClosingPairs: [ | ||
| { open: '{', close: '}' }, | ||
| { open: '[', close: ']' }, | ||
| { open: '(', close: ')' }, | ||
| { open: '"', close: '"' }, | ||
| { open: "'", close: "'" }, | ||
| { open: '`', close: '`' }, | ||
| ], | ||
| surroundingPairs: [ | ||
| { open: '{', close: '}' }, | ||
| { open: '[', close: ']' }, | ||
| { open: '(', close: ')' }, | ||
| { open: '"', close: '"' }, | ||
| { open: "'", close: "'" }, | ||
| { open: '`', close: '`' }, | ||
| ], | ||
| folding: { | ||
| offSide: false, | ||
| }, | ||
| }; |
| import type { languages } from 'monaco-editor'; | ||
| // Build regex patterns for each keyword category. | ||
| // Longer patterns must come first so alternation prefers them. | ||
| const pipelinePattern = 'json|logfmt|regexp|pattern|unpack|line_format|label_format|drop|keep|unwrap|decolorize'; | ||
| const rangeFuncPattern = | ||
| 'rate_counter|count_over_time|bytes_rate|bytes_over_time|' + | ||
| 'sum_over_time|avg_over_time|max_over_time|min_over_time|' + | ||
| 'first_over_time|last_over_time|stdvar_over_time|stddev_over_time|' + | ||
| 'quantile_over_time|absent_over_time|rate'; | ||
| const aggrPattern = 'sort_desc|approx_topk|stddev|stdvar|bottomk|topk|sum|avg|min|max|count|sort'; | ||
| const otherPattern = 'group_left|group_right|without|ignoring|offset|bool|vector|by|and|or|on'; | ||
| export function buildMonarchLanguage(): languages.IMonarchLanguage { | ||
| return { | ||
| defaultToken: '', | ||
| tokenPostfix: '.loki', | ||
| brackets: [ | ||
| { open: '{', close: '}', token: 'delimiter.curly' }, | ||
| { open: '[', close: ']', token: 'delimiter.square' }, | ||
| { open: '(', close: ')', token: 'delimiter.parenthesis' }, | ||
| ], | ||
| tokenizer: { | ||
| root: [ | ||
| // Whitespace | ||
| [/[ \t\r\n]+/, 'white'], | ||
| // Comments: # | ||
| [/#.*$/, 'comment'], | ||
| // Strings | ||
| [/"/, { token: 'string.quote', next: '@string_double' }], | ||
| [/'/, { token: 'string.quote', next: '@string_single' }], | ||
| [/`/, { token: 'string.quote', next: '@string_backtick' }], | ||
| // Duration | ||
| [/\d+(?:\.\d+)?(?:ns|us|µs|ms|s|m|h)\b/, 'type.duration'], | ||
| // Bytes | ||
| [/\d+(?:\.\d+)?(?:B|kB|MB|GB|TB|KB|KiB|MiB|GiB|TiB)\b/, 'type.bytes'], | ||
| // Numbers | ||
| [/\d+\.\d+/, 'number.float'], | ||
| [/\d+/, 'number'], | ||
| // Operators (order matters: longer patterns first) | ||
| [/!=/, 'operator'], | ||
| [/!~/, 'operator'], | ||
| [/=~/, 'operator'], | ||
| [/>=/, 'operator'], | ||
| [/<=/, 'operator'], | ||
| [/==/, 'operator'], | ||
| [/[=<>]/, 'operator'], | ||
| // Pipe operators | ||
| [/\|~/, 'operator.pipe'], | ||
| [/\|=/, 'operator.pipe'], | ||
| [/\|/, 'operator.pipe'], | ||
| // Brackets and punctuation | ||
| [/\{/, 'delimiter.curly'], | ||
| [/\}/, 'delimiter.curly'], | ||
| [/\[/, 'delimiter.square'], | ||
| [/\]/, 'delimiter.square'], | ||
| [/\(/, 'delimiter.parenthesis'], | ||
| [/\)/, 'delimiter.parenthesis'], | ||
| [/,/, 'delimiter.comma'], | ||
| // Keywords (must come before generic identifier) | ||
| [new RegExp('\\b(?:' + pipelinePattern + ')\\b'), 'keyword.pipeline'], | ||
| [new RegExp('\\b(?:' + rangeFuncPattern + ')\\b'), 'keyword.function'], | ||
| [new RegExp('\\b(?:' + aggrPattern + ')\\b'), 'keyword.aggregation'], | ||
| [new RegExp('\\b(?:' + otherPattern + ')\\b'), 'keyword'], | ||
| // Generic identifier | ||
| [/[a-zA-Z_][\w]*/, 'identifier'], | ||
| // Catch-all | ||
| [/[;:]/, 'delimiter'], | ||
| ], | ||
| string_double: [ | ||
| [/[^\\"]+/, 'string'], | ||
| [/\\./, 'string.escape'], | ||
| [/"/, { token: 'string.quote', next: '@pop' }], | ||
| ], | ||
| string_single: [ | ||
| [/[^\\']+/, 'string'], | ||
| [/\\./, 'string.escape'], | ||
| [/'/, { token: 'string.quote', next: '@pop' }], | ||
| ], | ||
| string_backtick: [ | ||
| [/[^`]+/, 'string'], | ||
| [/`/, { token: 'string.quote', next: '@pop' }], | ||
| ], | ||
| }, | ||
| } as languages.IMonarchLanguage; | ||
| } |
| export { tokenize } from './lexer'; | ||
| export { parse } from './parser'; | ||
| export { locate } from './locate'; | ||
| export { TokenKind } from './types'; | ||
| export type { Token, LokiQuery, Situation, SituationKind, PipelineStage, StreamSelector, Matcher } from './types'; |
| import { TokenKind } from './types'; | ||
| import type { Token } from './types'; | ||
| // ─── Character helpers ──────────────────────────────────────────── | ||
| function isAlpha(ch: string): boolean { | ||
| return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_'; | ||
| } | ||
| function isDigit(ch: string): boolean { | ||
| return ch >= '0' && ch <= '9'; | ||
| } | ||
| function isAlphaNum(ch: string): boolean { | ||
| return isAlpha(ch) || isDigit(ch); | ||
| } | ||
| function isWhitespace(ch: string): boolean { | ||
| return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; | ||
| } | ||
| // ─── Duration & Bytes regex helpers ─────────────────────────────── | ||
| const DURATION_UNITS = ['ns', 'us', 'µs', 'ms', 's', 'm', 'h']; | ||
| const BYTES_UNITS = ['B', 'kB', 'MB', 'GB', 'TB', 'KB', 'KiB', 'MiB', 'GiB', 'TiB']; | ||
| // ─── Tokenizer ──────────────────────────────────────────────────── | ||
| /** | ||
| * Convert an offset (0-based) to Monaco line/column. | ||
| * Monaco line/column are 1-based. | ||
| */ | ||
| export function offsetToPosition(input: string, offset: number): { lineNumber: number; column: number } { | ||
| let line = 1; | ||
| let col = 1; | ||
| for (let i = 0; i < offset && i < input.length; i++) { | ||
| if (input[i] === '\n') { | ||
| line++; | ||
| col = 1; | ||
| } else { | ||
| col++; | ||
| } | ||
| } | ||
| return { lineNumber: line, column: col }; | ||
| } | ||
| export function tokenize(input: string): Token[] { | ||
| const tokens: Token[] = []; | ||
| let pos = 0; | ||
| const len = input.length; | ||
| function peek(offset = 0): string { | ||
| const idx = pos + offset; | ||
| return idx < len ? input[idx] : '\0'; | ||
| } | ||
| function advance(): string { | ||
| return pos < len ? input[pos++] : '\0'; | ||
| } | ||
| function emit(kind: TokenKind, value: string, start?: number): void { | ||
| const s = start ?? pos - value.length; | ||
| tokens.push({ kind, value, start: s, end: s + value.length }); | ||
| } | ||
| function emitError(message: string, start: number, end: number): void { | ||
| tokens.push({ kind: TokenKind.Error, value: message, start, end }); | ||
| } | ||
| while (pos < len) { | ||
| const start = pos; | ||
| const ch = advance(); | ||
| // ── Whitespace ── | ||
| if (isWhitespace(ch)) { | ||
| while (pos < len && isWhitespace(peek())) advance(); | ||
| emit(TokenKind.Whitespace, input.slice(start, pos)); | ||
| continue; | ||
| } | ||
| // ── Comments ── | ||
| if (ch === '#') { | ||
| while (pos < len && peek() !== '\n') advance(); | ||
| emit(TokenKind.Comment, input.slice(start, pos)); | ||
| continue; | ||
| } | ||
| // ── Strings ── | ||
| if (ch === '"' || ch === "'" || ch === '`') { | ||
| const quote = ch; | ||
| let escaped = false; | ||
| while (pos < len) { | ||
| const c = advance(); | ||
| if (escaped) { | ||
| escaped = false; | ||
| continue; | ||
| } | ||
| if (c === '\\') { | ||
| escaped = true; | ||
| continue; | ||
| } | ||
| if (c === quote) { | ||
| break; | ||
| } | ||
| } | ||
| emit(TokenKind.String, input.slice(start, pos)); | ||
| continue; | ||
| } | ||
| // ── Pipe operators ── | ||
| if (ch === '|') { | ||
| if (peek() === '=') { | ||
| advance(); | ||
| emit(TokenKind.PipeEquals, '|='); | ||
| } else if (peek() === '~') { | ||
| advance(); | ||
| emit(TokenKind.PipeRegex, '|~'); | ||
| } else { | ||
| emit(TokenKind.Pipe, '|'); | ||
| } | ||
| continue; | ||
| } | ||
| // ── Operators starting with ! ── | ||
| if (ch === '!') { | ||
| if (peek() === '=') { | ||
| advance(); | ||
| emit(TokenKind.NotEquals, '!='); | ||
| } else if (peek() === '~') { | ||
| advance(); | ||
| emit(TokenKind.RegexNotEquals, '!~'); | ||
| } else { | ||
| emit(TokenKind.Error, '!', start); | ||
| } | ||
| continue; | ||
| } | ||
| // ── Operators starting with = ── | ||
| if (ch === '=') { | ||
| if (peek() === '~') { | ||
| advance(); | ||
| emit(TokenKind.RegexEquals, '=~'); | ||
| } else if (peek() === '=') { | ||
| // == (label filter equality) | ||
| advance(); | ||
| emit(TokenKind.Equals, '=='); | ||
| } else { | ||
| emit(TokenKind.Equals, '='); | ||
| } | ||
| continue; | ||
| } | ||
| // ── Comparison operators ── | ||
| if (ch === '>') { | ||
| if (peek() === '=') { | ||
| advance(); | ||
| emit(TokenKind.GreaterEquals, '>='); | ||
| } else { | ||
| emit(TokenKind.GreaterThan, '>'); | ||
| } | ||
| continue; | ||
| } | ||
| if (ch === '<') { | ||
| if (peek() === '=') { | ||
| advance(); | ||
| emit(TokenKind.LessEquals, '<='); | ||
| } else { | ||
| emit(TokenKind.LessThan, '<'); | ||
| } | ||
| continue; | ||
| } | ||
| // ── Brackets & punctuation ── | ||
| if (ch === '{') { | ||
| emit(TokenKind.LCurly, '{'); | ||
| continue; | ||
| } | ||
| if (ch === '}') { | ||
| emit(TokenKind.RCurly, '}'); | ||
| continue; | ||
| } | ||
| if (ch === '[') { | ||
| emit(TokenKind.LBracket, '['); | ||
| continue; | ||
| } | ||
| if (ch === ']') { | ||
| emit(TokenKind.RBracket, ']'); | ||
| continue; | ||
| } | ||
| if (ch === '(') { | ||
| emit(TokenKind.LParen, '('); | ||
| continue; | ||
| } | ||
| if (ch === ')') { | ||
| emit(TokenKind.RParen, ')'); | ||
| continue; | ||
| } | ||
| if (ch === ',') { | ||
| emit(TokenKind.Comma, ','); | ||
| continue; | ||
| } | ||
| // ── Numbers, Duration, Bytes ── | ||
| if (isDigit(ch)) { | ||
| // Consume digits and optional decimal | ||
| while (pos < len && (isDigit(peek()) || peek() === '.')) advance(); | ||
| const numStr = input.slice(start, pos); | ||
| // Check for Duration or Bytes suffix | ||
| let suffix = ''; | ||
| while (pos < len && isAlpha(peek())) { | ||
| suffix += advance(); | ||
| } | ||
| // Try to classify the full token | ||
| const fullStr = numStr + suffix; | ||
| if (suffix && DURATION_UNITS.includes(suffix)) { | ||
| emit(TokenKind.Duration, fullStr); | ||
| } else if (suffix && BYTES_UNITS.includes(suffix)) { | ||
| emit(TokenKind.Bytes, fullStr); | ||
| } else { | ||
| // It's just a number (suffix might be part of an adjacent identifier) | ||
| // Put back any consumed alpha chars that don't form a unit | ||
| // Actually we consumed them, better approach: check first | ||
| // For simplicity, emit as number. If suffix exists but isn't a unit, | ||
| // it'll be caught by the parser. | ||
| emit(TokenKind.Number, numStr); | ||
| // Re-emit the suffix as identifier if it's not empty and not a unit | ||
| if (suffix) { | ||
| pos = start + numStr.length; // backtrack to after the number | ||
| } | ||
| } | ||
| continue; | ||
| } | ||
| // ── Identifiers / Keywords ── | ||
| if (isAlpha(ch)) { | ||
| while (pos < len && (isAlphaNum(peek()) || peek() === '_')) advance(); | ||
| emit(TokenKind.Ident, input.slice(start, pos)); | ||
| continue; | ||
| } | ||
| // ── Anything else → error ── | ||
| emitError(`Unexpected character '${ch}'`, start, pos); | ||
| } | ||
| // Mark EOF | ||
| tokens.push({ kind: TokenKind.EOF, value: '', start: pos, end: pos }); | ||
| return tokens; | ||
| } | ||
| /** Return tokens with whitespace and comments filtered out. */ | ||
| export function nonWhitespace(tokens: Token[]): Token[] { | ||
| return tokens.filter((t) => t.kind !== TokenKind.Whitespace && t.kind !== TokenKind.Comment); | ||
| } |
| import { TokenKind } from './types'; | ||
| import type { Token, LokiQuery, Situation } from './types'; | ||
| import { tokenize, nonWhitespace } from './lexer'; | ||
| import { parse } from './parser'; | ||
| // Pipeline keywords that should NOT be treated as label filter start | ||
| const PIPELINE_KEYWORDS = new Set(['json', 'logfmt', 'regexp', 'pattern', 'unpack', 'line_format', 'label_format', 'drop', 'keep', 'unwrap', 'decolorize']); | ||
| // ─── Describe cursor position ───────────────────────────────────── | ||
| interface CursorDescription { | ||
| activeToken: Token | null; | ||
| prevToken: Token | null; | ||
| nextToken: Token | null; | ||
| prefix: string; | ||
| replaceStart: number; | ||
| } | ||
| function describeCursor(offset: number, tokens: Token[]): CursorDescription { | ||
| let activeToken: Token | null = null; | ||
| let prevToken: Token | null = null; | ||
| let nextToken: Token | null = null; | ||
| for (let i = 0; i < tokens.length; i++) { | ||
| const t = tokens[i]; | ||
| if (t.start <= offset && t.end >= offset) { | ||
| activeToken = t; | ||
| prevToken = i > 0 ? tokens[i - 1] : null; | ||
| nextToken = i + 1 < tokens.length ? tokens[i + 1] : null; | ||
| break; | ||
| } | ||
| if (t.start > offset) { | ||
| nextToken = t; | ||
| prevToken = i > 0 ? tokens[i - 1] : null; | ||
| break; | ||
| } | ||
| prevToken = t; | ||
| } | ||
| // If no active token found, cursor is at or past the end | ||
| if (!activeToken && tokens.length > 0) { | ||
| const last = tokens[tokens.length - 1]; | ||
| if (offset >= last.end) { | ||
| prevToken = last; | ||
| } | ||
| } | ||
| // Only consider the cursor inside a token when strictly between start and end. | ||
| // When cursor is at a token boundary, treat it as no active token for prefix purposes. | ||
| let prefix = ''; | ||
| let replaceStart = offset; | ||
| if (activeToken && offset > activeToken.start && offset < activeToken.end) { | ||
| // Cursor is inside a token — partial word being typed | ||
| prefix = activeToken.value.slice(0, offset - activeToken.start); | ||
| replaceStart = activeToken.start; | ||
| } else if (activeToken && offset === activeToken.end && activeToken.kind === TokenKind.Ident) { | ||
| // Cursor is at the end of an identifier — use the full word as prefix so | ||
| // Monaco can filter completions (e.g. "count" → matches count_over_time). | ||
| prefix = activeToken.value; | ||
| replaceStart = activeToken.start; | ||
| } | ||
| return { activeToken, prevToken, nextToken, prefix, replaceStart }; | ||
| } | ||
| // ─── Check if inside an unclosed string ─────────────────────────── | ||
| function insideUnclosedString(tokens: Token[], offset: number): boolean { | ||
| for (const t of tokens) { | ||
| if (t.kind === TokenKind.String && t.end >= offset && !isStringClosed(t.value)) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
| function isStringClosed(value: string): boolean { | ||
| if (value.length < 2) return false; | ||
| const quote = value[0]; | ||
| const endQuote = value[value.length - 1]; | ||
| // For backtick and single-quote, the last char should match first | ||
| if (quote === '`' || quote === "'") return endQuote === quote; | ||
| // For double-quote, check it's properly closed (odd number of backslashes before final quote) | ||
| if (quote === '"') { | ||
| if (endQuote !== '"') return false; | ||
| // Count trailing backslashes before the closing quote | ||
| let backslashes = 0; | ||
| for (let i = value.length - 2; i >= 1 && value[i] === '\\'; i--) { | ||
| backslashes++; | ||
| } | ||
| return backslashes % 2 === 0; | ||
| } | ||
| return false; | ||
| } | ||
| // ─── Main locate function ───────────────────────────────────────── | ||
| export function locate(offset: number, tokens: Token[]): Situation { | ||
| const { activeToken, prevToken, prefix, replaceStart } = describeCursor(offset, tokens); | ||
| // Helper: find label name by scanning backwards from a given token index | ||
| function findLabelBefore(idx: number): string | undefined { | ||
| for (let i = idx - 1; i >= 0; i--) { | ||
| const t = tokens[i]; | ||
| if (t.kind === TokenKind.Equals || t.kind === TokenKind.NotEquals || t.kind === TokenKind.RegexEquals || t.kind === TokenKind.RegexNotEquals) { | ||
| const nameToken = i > 0 ? tokens[i - 1] : null; | ||
| if (nameToken && nameToken.kind === TokenKind.Ident) return nameToken.value; | ||
| } | ||
| if (t.kind === TokenKind.LCurly) break; // don't go past { | ||
| } | ||
| return undefined; | ||
| } | ||
| // Check if we're inside { ... } (stream selector) | ||
| if (activeToken && activeToken.kind === TokenKind.LCurly && activeToken.start === offset) { | ||
| return { kind: 'IN_SELECTOR_LABEL', prefix, replaceRange: { start: replaceStart, end: offset } }; | ||
| } | ||
| // Check if cursor is at or after RCurly, but NOT inside a multi-matcher selector | ||
| if (activeToken && activeToken.kind === TokenKind.RCurly && activeToken.start === offset) { | ||
| // If there's a comma before RCurly, the user wants to add another label, | ||
| // not start a pipeline — let the stream selector check below handle it. | ||
| const hasCommaBefore = tokens.some((t) => t.kind === TokenKind.Comma && t.start < activeToken.start); | ||
| if (!hasCommaBefore) { | ||
| return { kind: 'AFTER_PIPE', prefix, replaceRange: { start: replaceStart, end: offset } }; | ||
| } | ||
| } | ||
| // Inside stream selector (look for LCurly before cursor) | ||
| const curlyOpenToken = tokens.find((t) => t.kind === TokenKind.LCurly); | ||
| const curlyCloseToken = tokens.find((t) => t.kind === TokenKind.RCurly); | ||
| if (curlyOpenToken && offset > curlyOpenToken.start) { | ||
| const beforeClose = curlyCloseToken ? offset < curlyCloseToken.end : true; | ||
| if (beforeClose) { | ||
| // ── After operator → value completion ── | ||
| if ( | ||
| activeToken && | ||
| (activeToken.kind === TokenKind.Equals || | ||
| activeToken.kind === TokenKind.NotEquals || | ||
| activeToken.kind === TokenKind.RegexEquals || | ||
| activeToken.kind === TokenKind.RegexNotEquals) | ||
| ) { | ||
| const opIdx = tokens.indexOf(activeToken); | ||
| const nameToken = opIdx > 0 ? tokens[opIdx - 1] : null; | ||
| // Count matchers before this one to get the matcher index | ||
| const matcherIndex = tokens | ||
| .slice(0, opIdx) | ||
| .filter((t) => t.kind === TokenKind.Equals || t.kind === TokenKind.NotEquals || t.kind === TokenKind.RegexEquals || t.kind === TokenKind.RegexNotEquals).length; | ||
| return { | ||
| kind: 'IN_SELECTOR_VALUE', | ||
| prefix, | ||
| replaceRange: { start: replaceStart, end: offset }, | ||
| labelName: nameToken?.value, | ||
| matcherIndex, | ||
| }; | ||
| } | ||
| // ── Inside a string value (e.g. {job="|"}) → value completion ── | ||
| if (activeToken && activeToken.kind === TokenKind.String) { | ||
| const strIdx = tokens.indexOf(activeToken); | ||
| const labelName = findLabelBefore(strIdx); | ||
| if (labelName) { | ||
| // Strip the opening quote from the prefix so Monaco doesn't | ||
| // filter all label values by the quote character. | ||
| const quote = activeToken.value[0]; | ||
| const innerPrefix = prefix.startsWith(quote) ? prefix.slice(quote.length) : prefix; | ||
| // Count matchers before THIS matcher: find the operator before the | ||
| // string, then count operators strictly before that operator. | ||
| const strIdx = tokens.indexOf(activeToken); | ||
| let opIdx = -1; | ||
| for (let i = strIdx - 1; i >= 0; i--) { | ||
| if ( | ||
| tokens[i].kind === TokenKind.Equals || | ||
| tokens[i].kind === TokenKind.NotEquals || | ||
| tokens[i].kind === TokenKind.RegexEquals || | ||
| tokens[i].kind === TokenKind.RegexNotEquals | ||
| ) { | ||
| opIdx = i; | ||
| break; | ||
| } | ||
| } | ||
| const matcherIndex = | ||
| opIdx >= 0 | ||
| ? tokens | ||
| .slice(0, opIdx) | ||
| .filter((t) => t.kind === TokenKind.Equals || t.kind === TokenKind.NotEquals || t.kind === TokenKind.RegexEquals || t.kind === TokenKind.RegexNotEquals).length | ||
| : 0; | ||
| return { | ||
| kind: 'IN_SELECTOR_VALUE', | ||
| prefix: innerPrefix, | ||
| replaceRange: { start: replaceStart + quote.length, end: offset }, | ||
| labelName, | ||
| insideString: true, | ||
| matcherIndex, | ||
| }; | ||
| } | ||
| } | ||
| // ── Default → label completion ── | ||
| return { kind: 'IN_SELECTOR_LABEL', prefix, replaceRange: { start: replaceStart, end: offset } }; | ||
| } | ||
| } | ||
| // Inside an unclosed string (not in stream selector) → no suggestions | ||
| if (insideUnclosedString(tokens, offset)) { | ||
| return { kind: 'NONE', prefix, replaceRange: { start: replaceStart, end: offset } }; | ||
| } | ||
| // After a pipe (check if prev token is a pipe or pipe operator) | ||
| if (prevToken) { | ||
| if (prevToken.kind === TokenKind.Pipe || prevToken.kind === TokenKind.PipeEquals || prevToken.kind === TokenKind.PipeRegex) { | ||
| // After a bare pipe - offer pipeline keywords | ||
| if (prevToken.kind === TokenKind.Pipe) { | ||
| return { | ||
| kind: 'AFTER_PIPE', | ||
| prefix, | ||
| replaceRange: { start: replaceStart, end: offset }, | ||
| }; | ||
| } | ||
| // After |= or |~ - already defined the operator, cursor is in the value position | ||
| return { | ||
| kind: 'NONE', | ||
| prefix, | ||
| replaceRange: { start: replaceStart, end: offset }, | ||
| }; | ||
| } | ||
| // Check for pipe followed by != or !~ | ||
| if (prevToken.kind === TokenKind.NotEquals || prevToken.kind === TokenKind.RegexNotEquals) { | ||
| // Check if the token before prevToken is a Pipe | ||
| const beforePrev = tokens[tokens.indexOf(prevToken) - 1]; | ||
| if (beforePrev && beforePrev.kind === TokenKind.Pipe) { | ||
| // This is a line filter operator | ||
| return { | ||
| kind: 'NONE', | ||
| prefix, | ||
| replaceRange: { start: replaceStart, end: offset }, | ||
| }; | ||
| } | ||
| } | ||
| } | ||
| // Inside function parentheses (but not inside range brackets) | ||
| let parenDepth = 0; | ||
| let bracketDepth = 0; | ||
| for (const t of tokens) { | ||
| if (t.kind === TokenKind.LParen) parenDepth++; | ||
| if (t.kind === TokenKind.LBracket) bracketDepth++; | ||
| if (t.start <= offset && t.end >= offset && parenDepth > 0 && bracketDepth === 0) { | ||
| return { | ||
| kind: 'IN_FUNCTION_PARENS', | ||
| prefix, | ||
| replaceRange: { start: replaceStart, end: offset }, | ||
| }; | ||
| } | ||
| if (t.kind === TokenKind.RParen) parenDepth--; | ||
| if (t.kind === TokenKind.RBracket) bracketDepth--; | ||
| } | ||
| // Default: offer pipeline stage completion if a pipe appeared before | ||
| const hasPipeBefore = tokens.some((t) => (t.kind === TokenKind.Pipe || t.kind === TokenKind.PipeEquals || t.kind === TokenKind.PipeRegex) && t.start < offset); | ||
| if (hasPipeBefore) { | ||
| // After a pipe operator (|=, !=, |~, !~) → typing value, no extra suggestions | ||
| if ( | ||
| prevToken && | ||
| (prevToken.kind === TokenKind.PipeEquals || | ||
| prevToken.kind === TokenKind.PipeRegex || | ||
| (prevToken.kind === TokenKind.NotEquals && tokens[tokens.indexOf(prevToken) - 1]?.kind === TokenKind.Pipe) || | ||
| (prevToken.kind === TokenKind.RegexNotEquals && tokens[tokens.indexOf(prevToken) - 1]?.kind === TokenKind.Pipe)) | ||
| ) { | ||
| return { | ||
| kind: 'NONE', | ||
| prefix, | ||
| replaceRange: { start: replaceStart, end: offset }, | ||
| }; | ||
| } | ||
| // Previous token is a label filter operator → typing a value | ||
| if ( | ||
| prevToken && | ||
| (prevToken.kind === TokenKind.GreaterThan || | ||
| prevToken.kind === TokenKind.GreaterEquals || | ||
| prevToken.kind === TokenKind.LessThan || | ||
| prevToken.kind === TokenKind.LessEquals || | ||
| prevToken.kind === TokenKind.Equals || | ||
| prevToken.kind === TokenKind.NotEquals || | ||
| prevToken.kind === TokenKind.RegexEquals || | ||
| prevToken.kind === TokenKind.RegexNotEquals) | ||
| ) { | ||
| return { | ||
| kind: 'IN_LABEL_FILTER', | ||
| prefix, | ||
| replaceRange: { start: replaceStart, end: offset }, | ||
| }; | ||
| } | ||
| // Previous token is an identifier that's NOT a pipeline keyword → label name in label filter | ||
| if (prevToken && prevToken.kind === TokenKind.Ident && !PIPELINE_KEYWORDS.has(prevToken.value)) { | ||
| return { | ||
| kind: 'IN_LABEL_FILTER', | ||
| prefix, | ||
| replaceRange: { start: replaceStart, end: offset }, | ||
| }; | ||
| } | ||
| } | ||
| // Inside range brackets [...] → no suggestions (duration values only) | ||
| let bDepth = 0; | ||
| for (const t of tokens) { | ||
| if (t.kind === TokenKind.LBracket && t.start < offset) bDepth++; | ||
| if (t.kind === TokenKind.RBracket && t.end < offset) bDepth--; | ||
| } | ||
| if (bDepth > 0) { | ||
| return { kind: 'NONE', prefix, replaceRange: { start: replaceStart, end: offset } }; | ||
| } | ||
| // Fallback: at root level, offer metric / aggregation functions | ||
| return { | ||
| kind: 'IN_FUNCTION_PARENS', | ||
| prefix, | ||
| replaceRange: { start: replaceStart, end: offset }, | ||
| }; | ||
| } | ||
| // ─── Convenience: parse + locate in one call ────────────────────── | ||
| export function parseAndLocate( | ||
| input: string, | ||
| offset: number, | ||
| ): { | ||
| ast: LokiQuery; | ||
| tokens: Token[]; | ||
| situation: Situation; | ||
| } { | ||
| const rawTokens = tokenize(input); | ||
| const tokens = nonWhitespace(rawTokens); | ||
| const ast = parse(input); | ||
| const situation = locate(offset, tokens); | ||
| return { ast, tokens, situation }; | ||
| } |
| import { TokenKind } from './types'; | ||
| import type { Token, LokiQuery, StreamSelector, Matcher, PipelineStage, LineFilter, MetricExpr, Grouping, ParseError } from './types'; | ||
| import { tokenize, nonWhitespace } from './lexer'; | ||
| // ─── Parser class ───────────────────────────────────────────────── | ||
| class Parser { | ||
| private tokens: Token[]; | ||
| private pos = 0; | ||
| errors: ParseError[] = []; | ||
| constructor(tokens: Token[]) { | ||
| this.tokens = tokens; | ||
| } | ||
| private peek(offset = 0): Token { | ||
| const idx = this.pos + offset; | ||
| return idx < this.tokens.length ? this.tokens[idx] : this.tokens[this.tokens.length - 1]; | ||
| } | ||
| private advance(): Token { | ||
| if (this.pos >= this.tokens.length) { | ||
| // Past the end — return the last token (EOF sentinel) to avoid undefined access | ||
| return this.tokens[this.tokens.length - 1]; | ||
| } | ||
| return this.tokens[this.pos++]; | ||
| } | ||
| private expect(kind: TokenKind): Token { | ||
| const token = this.advance(); | ||
| if (token.kind !== kind) { | ||
| this.errors.push({ | ||
| message: `Expected ${TokenKind[kind]} but got ${token.kind !== TokenKind.EOF ? token.value : 'EOF'}`, | ||
| start: token.start, | ||
| end: token.end, | ||
| }); | ||
| } | ||
| return token; | ||
| } | ||
| private expectAny(kinds: TokenKind[]): Token { | ||
| const token = this.advance(); | ||
| if (!kinds.includes(token.kind)) { | ||
| this.errors.push({ | ||
| message: `Expected one of [${kinds.map((k) => TokenKind[k]).join(', ')}] but got ${token.value || 'EOF'}`, | ||
| start: token.start, | ||
| end: token.end, | ||
| }); | ||
| } | ||
| return token; | ||
| } | ||
| private match(kind: TokenKind): boolean { | ||
| if (this.peek().kind === kind) { | ||
| this.advance(); | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
| // ── Top level ── | ||
| parse(): LokiQuery { | ||
| this.pos = 0; | ||
| this.errors = []; | ||
| let streamSelector: StreamSelector | null = null; | ||
| if (this.peek().kind === TokenKind.LCurly) { | ||
| streamSelector = this.parseStreamSelector(); | ||
| } | ||
| const pipeline: PipelineStage[] = []; | ||
| while (this.isPipeStart()) { | ||
| const stage = this.parsePipelineStage(); | ||
| if (stage) pipeline.push(stage); | ||
| } | ||
| let metricExpr: MetricExpr | null = null; | ||
| if (this.peek().kind === TokenKind.Ident && this.peek(1).kind === TokenKind.LParen) { | ||
| metricExpr = this.parseMetricExpr(); | ||
| } | ||
| return { | ||
| streamSelector, | ||
| pipeline, | ||
| metricExpr, | ||
| errors: this.errors, | ||
| }; | ||
| } | ||
| // ── Stream selector: { label op "value", ... } ── | ||
| private parseStreamSelector(): StreamSelector { | ||
| const matchers: Matcher[] = []; | ||
| this.expect(TokenKind.LCurly); | ||
| while (this.peek().kind !== TokenKind.RCurly && this.peek().kind !== TokenKind.EOF) { | ||
| // Skip comma | ||
| if (this.match(TokenKind.Comma)) continue; | ||
| // Skip whitespace/comments (should already be filtered out) | ||
| const labelTok = this.expectAny([TokenKind.Ident, TokenKind.String]); | ||
| if (labelTok.kind === TokenKind.EOF) break; | ||
| const opTok = this.expectAny([TokenKind.Equals, TokenKind.NotEquals, TokenKind.RegexEquals, TokenKind.RegexNotEquals]); | ||
| if (opTok.kind === TokenKind.EOF) break; | ||
| const valTok = this.expectAny([TokenKind.String, TokenKind.Ident, TokenKind.Number]); | ||
| if (valTok.kind === TokenKind.EOF) break; | ||
| matchers.push({ | ||
| label: labelTok.value, | ||
| operator: opTok.value, | ||
| value: valTok.value, | ||
| }); | ||
| } | ||
| this.expect(TokenKind.RCurly); | ||
| return { matchers }; | ||
| } | ||
| // ── Pipeline ── | ||
| private isPipeStart(): boolean { | ||
| const k = this.peek().kind; | ||
| return k === TokenKind.Pipe || k === TokenKind.PipeEquals || k === TokenKind.PipeRegex; | ||
| } | ||
| private parsePipelineStage(): PipelineStage | null { | ||
| const tok = this.peek(); | ||
| const kind = tok.kind; | ||
| if (kind === TokenKind.PipeEquals) { | ||
| this.advance(); | ||
| const valTok = this.peek().kind === TokenKind.String ? this.advance() : null; | ||
| return { | ||
| type: 'lineFilter', | ||
| operator: '|=', | ||
| value: valTok?.value ?? '', | ||
| } as LineFilter; | ||
| } | ||
| if (kind === TokenKind.PipeRegex) { | ||
| this.advance(); | ||
| const valTok = this.peek().kind === TokenKind.String ? this.advance() : null; | ||
| return { | ||
| type: 'lineFilter', | ||
| operator: '|~', | ||
| value: valTok?.value ?? '', | ||
| } as LineFilter; | ||
| } | ||
| if (kind === TokenKind.Pipe) { | ||
| this.advance(); // consume | | ||
| const next = this.peek(); | ||
| // Line filter: != or !~ | ||
| if (next.kind === TokenKind.NotEquals) { | ||
| this.advance(); | ||
| const valTok = this.peek().kind === TokenKind.String ? this.advance() : null; | ||
| return { type: 'lineFilter', operator: '!=', value: valTok?.value ?? '' } as LineFilter; | ||
| } | ||
| if (next.kind === TokenKind.RegexNotEquals) { | ||
| this.advance(); | ||
| const valTok = this.peek().kind === TokenKind.String ? this.advance() : null; | ||
| return { type: 'lineFilter', operator: '!~', value: valTok?.value ?? '' } as LineFilter; | ||
| } | ||
| // Keyword-based stages | ||
| if (next.kind === TokenKind.Ident) { | ||
| const keyword = next.value; | ||
| if (keyword === 'json') { | ||
| this.advance(); | ||
| return this.parseJsonParser(); | ||
| } | ||
| if (keyword === 'logfmt') { | ||
| this.advance(); | ||
| return this.parseLogfmtParser(); | ||
| } | ||
| if (keyword === 'regexp') { | ||
| this.advance(); | ||
| return this.parseRegexpParser(); | ||
| } | ||
| if (keyword === 'pattern') { | ||
| this.advance(); | ||
| return this.parseRegexpParser(); | ||
| } | ||
| if (keyword === 'unpack') { | ||
| this.advance(); | ||
| return { type: 'jsonParser' } as PipelineStage; | ||
| } | ||
| if (keyword === 'decolorize') { | ||
| this.advance(); | ||
| return { type: 'unrecognized', keyword: 'decolorize', rest: '' } as PipelineStage; | ||
| } | ||
| if (keyword === 'line_format' || keyword === 'label_format' || keyword === 'drop' || keyword === 'keep' || keyword === 'unwrap') { | ||
| this.advance(); | ||
| return { type: 'unrecognized', keyword, rest: '' } as PipelineStage; | ||
| } | ||
| // Not a pipeline keyword → treat as start of a label filter: | label op value | ||
| return this.parseLabelFilter(); | ||
| } | ||
| return null; | ||
| } | ||
| return null; | ||
| } | ||
| // ── Parsers ── | ||
| private parseJsonParser(): PipelineStage { | ||
| const expressions: string[] = []; | ||
| // Optional: | json field1, field2="expr" | ||
| while (this.peek().kind === TokenKind.Ident || this.peek().kind === TokenKind.String) { | ||
| const tok = this.advance(); | ||
| expressions.push(tok.value); | ||
| this.match(TokenKind.Comma); // optional comma | ||
| } | ||
| return { type: 'jsonParser', expressions: expressions.length > 0 ? expressions : undefined }; | ||
| } | ||
| private parseLogfmtParser(): PipelineStage { | ||
| const expressions: string[] = []; | ||
| while (this.peek().kind === TokenKind.Ident || this.peek().kind === TokenKind.String) { | ||
| const tok = this.advance(); | ||
| expressions.push(tok.value); | ||
| this.match(TokenKind.Comma); | ||
| } | ||
| return { type: 'logfmtParser', expressions: expressions.length > 0 ? expressions : undefined }; | ||
| } | ||
| private parseRegexpParser(): PipelineStage { | ||
| const patternTok = this.peek().kind === TokenKind.String ? this.advance() : null; | ||
| return { type: 'regexpParser', pattern: patternTok?.value ?? '' }; | ||
| } | ||
| // ── Label filter: label op value [and/or label op value] ── | ||
| private parseLabelFilter(): PipelineStage { | ||
| const conditions: import('./types').LabelFilterCondition[] = []; | ||
| while (this.peek().kind !== TokenKind.EOF && !this.isPipeStart() && this.peek().kind !== TokenKind.RParen) { | ||
| const labelTok = this.expectAny([TokenKind.Ident, TokenKind.String]); | ||
| if (labelTok.kind === TokenKind.EOF) break; | ||
| const opTok = this.expectAny([ | ||
| TokenKind.Equals, | ||
| TokenKind.NotEquals, | ||
| TokenKind.RegexEquals, | ||
| TokenKind.RegexNotEquals, | ||
| TokenKind.GreaterThan, | ||
| TokenKind.GreaterEquals, | ||
| TokenKind.LessThan, | ||
| TokenKind.LessEquals, | ||
| ]); | ||
| if (opTok.kind === TokenKind.EOF) break; | ||
| const valTok = this.expectAny([TokenKind.String, TokenKind.Number, TokenKind.Duration, TokenKind.Bytes, TokenKind.Ident]); | ||
| if (valTok.kind === TokenKind.EOF) break; | ||
| conditions.push({ | ||
| label: labelTok.value, | ||
| operator: opTok.value, | ||
| value: valTok.value, | ||
| }); | ||
| // Optional and/or | ||
| if (this.peek().kind === TokenKind.Ident && (this.peek().value === 'and' || this.peek().value === 'or')) { | ||
| this.advance(); | ||
| } else { | ||
| break; | ||
| } | ||
| } | ||
| return { type: 'labelFilter', conditions }; | ||
| } | ||
| // ── Metric expression: func([5m]) ── | ||
| private parseMetricExpr(): MetricExpr { | ||
| const nameTok = this.advance(); // function name | ||
| this.expect(TokenKind.LParen); | ||
| // Could be nested query or range | ||
| let range = ''; | ||
| if (this.peek().kind === TokenKind.LCurly) { | ||
| // Nested query: sum(rate({...}[5m])) | ||
| // Simple parse: skip until '[' or ')' | ||
| while (this.peek().kind !== TokenKind.EOF && this.peek().kind !== TokenKind.RParen) { | ||
| if (this.peek().kind === TokenKind.LBracket) break; | ||
| this.advance(); | ||
| } | ||
| } | ||
| // Parse range: [5m] | ||
| if (this.peek().kind === TokenKind.LBracket) { | ||
| this.advance(); // consume [ | ||
| const rangeStart = this.pos; | ||
| while (this.peek().kind !== TokenKind.RBracket && this.peek().kind !== TokenKind.EOF) { | ||
| this.advance(); | ||
| } | ||
| range = | ||
| this.peek().kind === TokenKind.RBracket | ||
| ? this.tokens | ||
| .slice(rangeStart, this.pos) | ||
| .map((t) => t.value) | ||
| .join('') | ||
| : ''; | ||
| this.expect(TokenKind.RBracket); | ||
| } | ||
| this.expect(TokenKind.RParen); | ||
| // Parse optional grouping: by(...) / without(...) | ||
| let grouping: Grouping | undefined; | ||
| if (this.peek().kind === TokenKind.Ident && (this.peek().value === 'by' || this.peek().value === 'without')) { | ||
| grouping = this.parseGrouping(); | ||
| } | ||
| return { func: nameTok.value, range, grouping }; | ||
| } | ||
| private parseGrouping(): Grouping { | ||
| const by = this.peek().value === 'by'; | ||
| this.advance(); // consume by/without | ||
| this.expect(TokenKind.LParen); | ||
| const labels: string[] = []; | ||
| while (this.peek().kind !== TokenKind.RParen && this.peek().kind !== TokenKind.EOF) { | ||
| if (this.match(TokenKind.Comma)) continue; | ||
| const tok = this.expectAny([TokenKind.Ident, TokenKind.String]); | ||
| if (tok.kind !== TokenKind.EOF) labels.push(tok.value); | ||
| } | ||
| this.expect(TokenKind.RParen); | ||
| return { by, labels }; | ||
| } | ||
| } | ||
| // ─── Public API ─────────────────────────────────────────────────── | ||
| export function parse(input: string): LokiQuery { | ||
| const rawTokens = tokenize(input); | ||
| const tokens = nonWhitespace(rawTokens); | ||
| const parser = new Parser(tokens); | ||
| return parser.parse(); | ||
| } |
| // ─── Token types ────────────────────────────────────────────────── | ||
| export enum TokenKind { | ||
| LCurly, // { | ||
| RCurly, // } | ||
| Pipe, // | | ||
| PipeEquals, // |= | ||
| PipeRegex, // |~ | ||
| Equals, // = | ||
| NotEquals, // != | ||
| RegexEquals, // =~ | ||
| RegexNotEquals, // !~ | ||
| GreaterThan, // > | ||
| GreaterEquals, // >= | ||
| LessThan, // < | ||
| LessEquals, // <= | ||
| String, // "...", '...', `...` | ||
| Number, // 123, 45.6 | ||
| Duration, // 5m, 1h30m | ||
| Bytes, // 42MB | ||
| Ident, // identifier / keyword | ||
| Comma, // , | ||
| LParen, // ( | ||
| RParen, // ) | ||
| LBracket, // [ | ||
| RBracket, // ] | ||
| Comment, // #... | ||
| Whitespace, | ||
| EOF, | ||
| Error, | ||
| } | ||
| export interface Token { | ||
| kind: TokenKind; | ||
| value: string; | ||
| start: number; | ||
| end: number; | ||
| } | ||
| // ─── AST types ──────────────────────────────────────────────────── | ||
| export interface LokiQuery { | ||
| streamSelector: StreamSelector | null; | ||
| pipeline: PipelineStage[]; | ||
| metricExpr: MetricExpr | null; | ||
| errors: ParseError[]; | ||
| } | ||
| export interface StreamSelector { | ||
| matchers: Matcher[]; | ||
| } | ||
| export interface Matcher { | ||
| label: string; | ||
| operator: string; // = != =~ !~ | ||
| value: string; | ||
| } | ||
| export type PipelineStage = LineFilter | JsonParser | LogfmtParser | RegexpParser | LabelFilter | UnrecognizedStage; | ||
| export interface LineFilter { | ||
| type: 'lineFilter'; | ||
| operator: string; // |= != |~ !~ | ||
| value: string; | ||
| } | ||
| export interface JsonParser { | ||
| type: 'jsonParser'; | ||
| expressions?: string[]; | ||
| } | ||
| export interface LogfmtParser { | ||
| type: 'logfmtParser'; | ||
| expressions?: string[]; | ||
| } | ||
| export interface RegexpParser { | ||
| type: 'regexpParser'; | ||
| pattern: string; | ||
| } | ||
| export interface LabelFilter { | ||
| type: 'labelFilter'; | ||
| conditions: LabelFilterCondition[]; | ||
| } | ||
| export interface LabelFilterCondition { | ||
| label: string; | ||
| operator: string; | ||
| value: string; | ||
| } | ||
| export interface UnrecognizedStage { | ||
| type: 'unrecognized'; | ||
| keyword: string; | ||
| rest: string; | ||
| } | ||
| export interface MetricExpr { | ||
| func: string; | ||
| range: string; | ||
| grouping?: Grouping; | ||
| } | ||
| export interface Grouping { | ||
| by: boolean; // true = by, false = without | ||
| labels: string[]; | ||
| } | ||
| export interface ParseError { | ||
| message: string; | ||
| start: number; | ||
| end: number; | ||
| } | ||
| // ─── Completion situation ───────────────────────────────────────── | ||
| export type SituationKind = 'NONE' | 'IN_SELECTOR_LABEL' | 'IN_SELECTOR_VALUE' | 'AFTER_PIPE' | 'IN_PIPELINE_ARG' | 'IN_LABEL_FILTER' | 'IN_FUNCTION_PARENS' | 'IN_GROUPING'; | ||
| export interface Situation { | ||
| kind: SituationKind; | ||
| prefix: string; | ||
| replaceRange: { start: number; end: number }; | ||
| /** The label name when completing a selector value (IN_SELECTOR_VALUE). */ | ||
| labelName?: string; | ||
| /** The index of the matcher being edited within the stream selector (IN_SELECTOR_VALUE). */ | ||
| matcherIndex?: number; | ||
| /** True when cursor is inside an existing string literal (quotes already present). */ | ||
| insideString?: boolean; | ||
| } |
| # Loki LogQL Editor | ||
| A Monaco-based editor component for **Grafana Loki LogQL** query language. | ||
| ## Features | ||
| - **Log queries**: stream selectors + log pipeline (line filters, parsers, label filters) | ||
| - **Metric queries**: Range Vector Aggregations (`rate`, `count_over_time`, ...) | ||
| - **Vector Aggregation Operators**: `sum`, `avg`, `topk`, `sort`, ... | ||
| - **Syntax highlighting** via Monarch tokenizer | ||
| - **Context-aware autocompletion** with async label name/value fetching | ||
| - **Variables**: `${name}` completion (only in label value context) | ||
| - **Test suite**: 87 unit tests covering lexer, parser, locate, and completions | ||
| ## Usage | ||
| ```tsx | ||
| import { LokiMonacoEditor } from '@fc-components/monaco-editor'; | ||
| function MyComponent() { | ||
| const [query, setQuery] = React.useState('{job="nginx"} |= "error"'); | ||
| return ( | ||
| <LokiMonacoEditor | ||
| value={query} | ||
| onChange={setQuery} | ||
| onEnter={(v) => executeQuery(v)} | ||
| enableAutocomplete | ||
| variables={['host', 'env']} | ||
| fetchLabelNames={async (currentMatchers) => ['job', 'app', 'level']} | ||
| fetchLabelValues={async (name, currentMatchers) => (name === 'job' ? ['nginx', 'api'] : [])} | ||
| /> | ||
| ); | ||
| } | ||
| ``` | ||
| ## Props | ||
| | Prop | Type | Default | Description | | ||
| | -------------------- | -------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------- | | ||
| | `value` | `string` | `''` | Current query value | | ||
| | `onChange` | `(value: string) => void` | — | Called on every content change | | ||
| | `onEnter` | `(value: string) => void` | — | Called when Enter is pressed (not in suggestion widget) | | ||
| | `onBlur` | `(value: string) => void` | — | Called on blur | | ||
| | `onFocus` | `(value: string) => void` | — | Called on focus | | ||
| | `theme` | `'light' \| 'dark'` | `'light'` | Color theme | | ||
| | `size` | `'small' \| 'middle' \| 'large'` | `'middle'` | Matches antd input sizes | | ||
| | `placeholder` | `string` | — | Placeholder text | | ||
| | `fontSize` | `number` | — | Font size in px | | ||
| | `maxHeight` | `number \| string` | — | Max height of editor | | ||
| | `disabled` | `boolean` | `false` | Disable editing | | ||
| | `readOnly` | `boolean` | `false` | Read-only mode | | ||
| | `enableAutocomplete` | `boolean` | `true` | Enable autocompletion | | ||
| | `variables` | `string[]` | `[]` | Variable names for `${name}` completion (only in label value context) | | ||
| | `fetchLabelNames` | `(currentMatchers: LabelMatcher[]) => Promise<string[]>` | — | Async provider for label names (receives existing matchers for context) | | ||
| | `fetchLabelValues` | `(name: string, currentMatchers: LabelMatcher[]) => ...` | — | Async provider for label values (receives other matchers, excludes current one) | | ||
| | `editorDidMount` | `(editor) => void` | — | Called after editor mounts | | ||
| ### LabelMatcher | ||
| ```typescript | ||
| interface LabelMatcher { | ||
| label: string; // e.g. "job" | ||
| operator: string; // e.g. "=", "!=", "=~", "!~" | ||
| value: string; // e.g. "nginx" (includes surrounding quotes) | ||
| } | ||
| ``` | ||
| ## Supported Syntax | ||
| ### Stream Selector | ||
| ``` | ||
| {job="mysql"} | ||
| {app=~"nginx|api", method!="DELETE"} | ||
| ``` | ||
| ### Line Filters | ||
| ``` | ||
| |= "error" # Contains | ||
| != "timeout" # Does not contain | ||
| |~ "reg.*ex" # Regex match | ||
| !~ "reg.*ex" # Regex not match | ||
| ``` | ||
| ### Parsers | ||
| ``` | ||
| | json # Extract all JSON fields | ||
| | logfmt # Extract logfmt key-value pairs | ||
| | regexp "(?P<method>\\w+)" # Extract with regex | ||
| | pattern "<method> <path>" # Extract with pattern | ||
| | unpack # Unpack packed log lines | ||
| ``` | ||
| ### Label Filters | ||
| ``` | ||
| | status >= 400 | ||
| | level = "error" and duration > 1s | ||
| | bytes_consumed > 20MB | ||
| ``` | ||
| ### Format & Label Operations | ||
| ``` | ||
| | line_format "{{.label}}" | ||
| | label_format dst=src | ||
| | drop label_name | ||
| | keep label_name | ||
| | unwrap label | ||
| | decolorize | ||
| ``` | ||
| ### Range Vector Aggregations | ||
| ``` | ||
| rate({job="nginx"}[5m]) | ||
| rate_counter({job="nginx"}[5m]) | ||
| count_over_time({job="api"}[1h]) | ||
| bytes_rate({job="nginx"}[5m]) | ||
| bytes_over_time({job="api"}[1h]) | ||
| sum_over_time({job="nginx"}[5m]) | ||
| avg_over_time({job="nginx"}[5m]) | ||
| max_over_time({job="nginx"}[5m]) | ||
| min_over_time({job="nginx"}[5m]) | ||
| first_over_time({job="nginx"}[5m]) | ||
| last_over_time({job="nginx"}[5m]) | ||
| stdvar_over_time({job="nginx"}[5m]) | ||
| stddev_over_time({job="nginx"}[5m]) | ||
| quantile_over_time(0.95, {job="nginx"}[5m]) | ||
| absent_over_time({job="nginx"}[5m]) | ||
| ``` | ||
| ### Vector Aggregation Operators | ||
| ``` | ||
| sum by(label) (query) | ||
| avg by(label) (query) | ||
| min by(label) (query) | ||
| max by(label) (query) | ||
| count by(label) (query) | ||
| stddev by(label) (query) | ||
| stdvar by(label) (query) | ||
| topk(5, query) | ||
| bottomk(5, query) | ||
| sort(query) | ||
| sort_desc(query) | ||
| ``` | ||
| ### Metric Queries | ||
| ``` | ||
| rate({job="nginx"}[5m]) | ||
| count_over_time({job="api"}[1h]) | ||
| sum(rate({app="nginx"}[5m])) | ||
| ``` | ||
| ## Completion Behavior | ||
| | Context | Suggestions | | ||
| | -------------------------- | -------------------------------------------------- | | ||
| | `{\|` | Label names (from `fetchLabelNames`) | | ||
| | `{job=\|}` or `{job="\|"}` | Variables + label values (from `fetchLabelValues`) | | ||
| | `\|` | Line filter operators + pipeline stage keywords | | ||
| | Inside `\| ...` | Label filter operators (`>=`, `<=`, `=`, ...) | | ||
| | Inside `(...)` | Metric & aggregation functions | | ||
| | Inside `[...]` | (none — range values only) | | ||
| | Root level (empty editor) | Metric & aggregation functions + stream selector | | ||
| - **Variables** (`${host}` etc.) only appear in label value context (`IN_SELECTOR_VALUE`) | ||
| - **Label names** are inserted as plain text (user types `=` to trigger value completion) | ||
| - **Values** are auto-wrapped in quotes when outside a string, inserted raw when inside | ||
| - **Functions** insert just the function name (no template/snippet) | ||
| ## Architecture | ||
| ``` | ||
| src/loki/ | ||
| ├── index.tsx ← React component (LokiMonacoEditor) | ||
| ├── loki.ts ← Monaco language configuration | ||
| ├── types.ts ← Props & types (LabelMatcher, LokiEditorProps) | ||
| ├── README.md ← This file | ||
| ├── monarch/ | ||
| │ └── buildLanguage.ts ← Monarch tokenizer (syntax highlighting) | ||
| ├── parser/ | ||
| │ ├── index.ts ← Re-exports | ||
| │ ├── types.ts ← Token, AST (LokiQuery, PipelineStage), Situation types | ||
| │ ├── lexer.ts ← Tokenizer (handles Duration, Bytes, comments, strings) | ||
| │ ├── parser.ts ← Recursive descent parser (selector → pipeline → metric) | ||
| │ └── locate.ts ← Cursor position analyzer (determines completion context) | ||
| ├── completion/ | ||
| │ ├── completions.ts ← Completion item builder (context-aware suggestions) | ||
| │ └── getCompletionProvider.ts ← Monaco CompletionItemProvider with async data | ||
| └── __tests__/ | ||
| ├── lexer.test.ts ← 18 tests | ||
| ├── parser.test.ts ← 18 tests | ||
| ├── locate.test.ts ← 24 tests | ||
| └── completions.test.ts ← 27 tests | ||
| ``` |
| import type monaco from 'monaco-editor'; | ||
| /** A single label matcher from the stream selector, e.g. {job="nginx"}. */ | ||
| export interface LabelMatcher { | ||
| label: string; | ||
| operator: string; | ||
| value: string; | ||
| } | ||
| export interface LokiEditorProps { | ||
| className?: string; | ||
| maxHeight?: number | string; | ||
| fontSize?: number; | ||
| size?: 'small' | 'middle' | 'large'; | ||
| theme?: 'light' | 'dark'; | ||
| value?: string; | ||
| placeholder?: string; | ||
| enableAutocomplete?: boolean; | ||
| readOnly?: boolean; | ||
| disabled?: boolean; | ||
| variables?: string[]; | ||
| onChange?: (value: string) => void; | ||
| onEnter?: (value: string) => void; | ||
| onBlur?: (value: string) => void; | ||
| onFocus?: (value: string) => void; | ||
| editorDidMount?: (editor: monaco.editor.IStandaloneCodeEditor) => void; | ||
| /** Async provider for label names used in stream selector completion. Receives current matchers for context. */ | ||
| fetchLabelNames?: (currentMatchers: LabelMatcher[]) => Promise<string[]>; | ||
| /** Async provider for label values used in stream selector completion. Receives current matchers for context. */ | ||
| fetchLabelValues?: (labelName: string, currentMatchers: LabelMatcher[]) => Promise<string[]>; | ||
| } | ||
| export interface LokiCompletionContext { | ||
| prefix: string; | ||
| } |
@@ -15,4 +15,6 @@ export declare const MarkerSeverity: { | ||
| Variable: number; | ||
| Property: number; | ||
| Field: number; | ||
| Value: number; | ||
| Operator: number; | ||
| Snippet: number; | ||
@@ -34,1 +36,7 @@ }; | ||
| export declare const Range: () => void; | ||
| export declare type IRange = { | ||
| startLineNumber: number; | ||
| startColumn: number; | ||
| endLineNumber: number; | ||
| endColumn: number; | ||
| }; |
+3
-0
@@ -7,2 +7,3 @@ import promql from './promql'; | ||
| import logql from './logql'; | ||
| import loki from './loki'; | ||
| export { promql as PromQLMonacoEditor }; | ||
@@ -14,2 +15,4 @@ export { yaml as YamlMonacoEditor }; | ||
| export { logql as LogQLMonacoEditor }; | ||
| export { loki as LokiMonacoEditor }; | ||
| export type { LogQLEditorProps, LogQLVendor, LogQLSection, LogQLFieldCompletionContext, VendorConfig } from './logql/types'; | ||
| export type { LokiEditorProps, LabelMatcher } from './loki/types'; |
| import { Label } from '../types'; | ||
| /** | ||
| * Safe string literal parser that never throws. Falls back to returning | ||
| * the original text for unrecognised formats. | ||
| */ | ||
| export declare function parseStringLiteralSafe(text: string): string; | ||
| export declare type Situation = { | ||
@@ -3,0 +8,0 @@ type: 'IN_FUNCTION'; |
+1
-1
@@ -6,3 +6,3 @@ { | ||
| }, | ||
| "version": "0.4.3", | ||
| "version": "0.5.0", | ||
| "license": "MIT", | ||
@@ -9,0 +9,0 @@ "main": "dist/index.js", |
@@ -17,4 +17,6 @@ export const MarkerSeverity = { | ||
| Variable: 12, | ||
| Property: 9, | ||
| Field: 5, | ||
| Value: 13, | ||
| Operator: 11, | ||
| Snippet: 27, | ||
@@ -40,1 +42,3 @@ }, | ||
| export const Range = function () {}; | ||
| export type IRange = { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }; |
+3
-0
@@ -7,2 +7,3 @@ import promql from './promql'; | ||
| import logql from './logql'; | ||
| import loki from './loki'; | ||
@@ -15,2 +16,4 @@ export { promql as PromQLMonacoEditor }; | ||
| export { logql as LogQLMonacoEditor }; | ||
| export { loki as LokiMonacoEditor }; | ||
| export type { LogQLEditorProps, LogQLVendor, LogQLSection, LogQLFieldCompletionContext, VendorConfig } from './logql/types'; | ||
| export type { LokiEditorProps, LabelMatcher } from './loki/types'; |
@@ -122,2 +122,15 @@ import type { SyntaxNode, Tree } from '@lezer/common'; | ||
| /** | ||
| * Safe string literal parser that never throws. Falls back to returning | ||
| * the original text for unrecognised formats. | ||
| */ | ||
| export function parseStringLiteralSafe(text: string): string { | ||
| if (text.length < 2) return text; | ||
| try { | ||
| return parsePromQLStringLiteral(text); | ||
| } catch { | ||
| return text; | ||
| } | ||
| } | ||
| export type Situation = | ||
@@ -285,3 +298,3 @@ | { | ||
| const name = getNodeText(nameNode, text); | ||
| const value = parsePromQLStringLiteral(getNodeText(valueNode, text)); | ||
| const value = parseStringLiteralSafe(getNodeText(valueNode, text)); | ||
@@ -288,0 +301,0 @@ return { name, value, op }; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
3143357
24.41%151
21.77%33510
24.22%