mirror of https://github.com/grafana/grafana
Loki: query editor using Monaco (#55391)
* loki: switch to a monaco-based query field, step 1 (#46291) * loki: use monaco-logql (#46318) * loki: use monaco-logql * updated monaco-logql * fix all the tests (#46327) * loki: recommend parser (#46362) * loki: recommend parser * additional improvements * more improvements * type and lint fixes * more improvements * trigger autocomplete on focus * rename * loki: more smart features (#46414) * loki: more smart features * loki: updated syntax-highlight version * better explanation (#46443) * better explanation * improved help-text Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> * Fix label * feat(loki-monaco-editor): add monaco-logql as a dependency * feat(loki-monaco-editor): add back range function removed during merge * feat(loki-monaco-editor): sync imports with recent changes * feat(loki-monaco-editor): add missing lang provider functions * feat(loki-monaco-editor): fix imports * feat(loki-monaco-editor): display monaco editor by default Temporarily * Chore: remove commented code * Chore: minor refactor to NeverCaseError * Chore: minor code cleanups * feat(loki-monaco-editor): add history implementation Will see how it behaves and base the history slicing on tangible feedback * feat(loki-monaco-editor): turn completion data provider into a class * Chore: fix missing imports * feat(loki-monaco-editor): refactor data provider methods Move complexity scattered everywhere to the provider * Chore: clean up redundant code * Chore: minor comments cleanup * Chore: simplify override services * Chore: rename callback * feat(loki-monaco-editor): use query hints implementation to parse expression * feat(loki-monaco-editor): improve function name * Chore: remove superfluous variable in favor of destructuring * Chore: remove unused imports * Chore: make method async * feat(loki-monaco-editor): fix deprecations and errors in situation * feat(loki-monaco-editor): comment failing test case * Chore: remove comment from test * Chore: remove duplicated completion item * Chore: fix linting issues * Chore: update language provider test * Chore: update datasource test * feat(loki-monaco-editor): create feature flag * feat(loki-monaco-editor): place the editor under a feature flag * Chore: add completion unit test * Chore: add completion data provider test * Chore: remove unwanted export * Chore: remove unused export * Chore(loki-query-field): destructure all props * chore(loki-completions): remove odd string * fix(loki-completions): remove rate_interval Not supported * fix(loki-completions): remove line filters for after pipe case We shouldn't offer line filters if we are after first pipe. * refactor(loki-datasource): update default parameter * fix(loki-syntax): remove outdated documentation * Update capitalization in pkg/services/featuremgmt/registry.go Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> * refactor(situation): use node types instead of names * Chore: comment line filters pending implementation It's breaking the build due to a linting error. * Chore: update feature flag test after capitalization change * Revert "fix(loki-completions): remove line filters for after pipe case" This reverts commitpull/56494/head3d003ca4bc. * Revert "Chore: comment line filters pending implementation" This reverts commit84bfe76a6a. Co-authored-by: Gábor Farkas <gabor.farkas@gmail.com> Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Co-authored-by: Ivana Huckova <ivana.huckova@gmail.com>
parent
8fd4fcb987
commit
729ce8bb72
@ -0,0 +1,184 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import React, { useRef, useEffect } from 'react'; |
||||||
|
import { useLatest } from 'react-use'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { selectors } from '@grafana/e2e-selectors'; |
||||||
|
import { languageConfiguration, monarchlanguage } from '@grafana/monaco-logql'; |
||||||
|
import { useTheme2, ReactMonacoEditor, Monaco, monacoTypes } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { Props } from './MonacoQueryFieldProps'; |
||||||
|
import { getOverrideServices } from './getOverrideServices'; |
||||||
|
import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider'; |
||||||
|
import { CompletionDataProvider } from './monaco-completion-provider/CompletionDataProvider'; |
||||||
|
|
||||||
|
const options: monacoTypes.editor.IStandaloneEditorConstructionOptions = { |
||||||
|
codeLens: false, |
||||||
|
contextmenu: false, |
||||||
|
// we need `fixedOverflowWidgets` because otherwise in grafana-dashboards
|
||||||
|
// the popup is clipped by the panel-visualizations.
|
||||||
|
fixedOverflowWidgets: true, |
||||||
|
folding: false, |
||||||
|
fontSize: 14, |
||||||
|
lineDecorationsWidth: 8, // used as "padding-left"
|
||||||
|
lineNumbers: 'off', |
||||||
|
minimap: { enabled: false }, |
||||||
|
overviewRulerBorder: false, |
||||||
|
overviewRulerLanes: 0, |
||||||
|
padding: { |
||||||
|
// these numbers were picked so that visually this matches the previous version
|
||||||
|
// of the query-editor the best
|
||||||
|
top: 4, |
||||||
|
bottom: 5, |
||||||
|
}, |
||||||
|
renderLineHighlight: 'none', |
||||||
|
scrollbar: { |
||||||
|
vertical: 'hidden', |
||||||
|
verticalScrollbarSize: 8, // used as "padding-right"
|
||||||
|
horizontal: 'hidden', |
||||||
|
horizontalScrollbarSize: 0, |
||||||
|
}, |
||||||
|
scrollBeyondLastLine: false, |
||||||
|
suggest: getSuggestOptions(), |
||||||
|
suggestFontSize: 12, |
||||||
|
wordWrap: 'on', |
||||||
|
}; |
||||||
|
|
||||||
|
// this number was chosen by testing various values. it might be necessary
|
||||||
|
// because of the width of the border, not sure.
|
||||||
|
//it needs to do 2 things:
|
||||||
|
// 1. when the editor is single-line, it should make the editor height be visually correct
|
||||||
|
// 2. when the editor is multi-line, the editor should not be "scrollable" (meaning,
|
||||||
|
// you do a scroll-movement in the editor, and it will scroll the content by a couple pixels
|
||||||
|
// up & down. this we want to avoid)
|
||||||
|
const EDITOR_HEIGHT_OFFSET = 2; |
||||||
|
|
||||||
|
const LANG_ID = 'logql'; |
||||||
|
|
||||||
|
// we must only run the lang-setup code once
|
||||||
|
let LANGUAGE_SETUP_STARTED = false; |
||||||
|
|
||||||
|
function ensureLogQL(monaco: Monaco) { |
||||||
|
if (LANGUAGE_SETUP_STARTED === false) { |
||||||
|
LANGUAGE_SETUP_STARTED = true; |
||||||
|
monaco.languages.register({ id: LANG_ID }); |
||||||
|
|
||||||
|
monaco.languages.setMonarchTokensProvider(LANG_ID, monarchlanguage); |
||||||
|
monaco.languages.setLanguageConfiguration(LANG_ID, languageConfiguration); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => { |
||||||
|
return { |
||||||
|
container: css` |
||||||
|
border-radius: ${theme.shape.borderRadius()}; |
||||||
|
border: 1px solid ${theme.components.input.borderColor}; |
||||||
|
`,
|
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
const MonacoQueryField = ({ languageProvider, history, onBlur, onRunQuery, initialValue }: Props) => { |
||||||
|
// we need only one instance of `overrideServices` during the lifetime of the react component
|
||||||
|
const overrideServicesRef = useRef(getOverrideServices()); |
||||||
|
const containerRef = useRef<HTMLDivElement>(null); |
||||||
|
|
||||||
|
const langProviderRef = useLatest(languageProvider); |
||||||
|
const historyRef = useLatest(history); |
||||||
|
const onRunQueryRef = useLatest(onRunQuery); |
||||||
|
const onBlurRef = useLatest(onBlur); |
||||||
|
|
||||||
|
const autocompleteCleanupCallback = useRef<(() => void) | null>(null); |
||||||
|
|
||||||
|
const theme = useTheme2(); |
||||||
|
const styles = getStyles(theme); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
// when we unmount, we unregister the autocomplete-function, if it was registered
|
||||||
|
return () => { |
||||||
|
autocompleteCleanupCallback.current?.(); |
||||||
|
}; |
||||||
|
}, []); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
aria-label={selectors.components.QueryField.container} |
||||||
|
className={styles.container} |
||||||
|
// NOTE: we will be setting inline-style-width/height on this element
|
||||||
|
ref={containerRef} |
||||||
|
> |
||||||
|
<ReactMonacoEditor |
||||||
|
overrideServices={overrideServicesRef.current} |
||||||
|
options={options} |
||||||
|
language={LANG_ID} |
||||||
|
value={initialValue} |
||||||
|
beforeMount={(monaco) => { |
||||||
|
ensureLogQL(monaco); |
||||||
|
}} |
||||||
|
onMount={(editor, monaco) => { |
||||||
|
// we setup on-blur
|
||||||
|
editor.onDidBlurEditorWidget(() => { |
||||||
|
onBlurRef.current(editor.getValue()); |
||||||
|
}); |
||||||
|
const dataProvider = new CompletionDataProvider(langProviderRef.current, historyRef.current); |
||||||
|
const completionProvider = getCompletionProvider(monaco, dataProvider); |
||||||
|
|
||||||
|
// completion-providers in monaco are not registered directly to editor-instances,
|
||||||
|
// they are registered to languages. this makes it hard for us to have
|
||||||
|
// separate completion-providers for every query-field-instance
|
||||||
|
// (but we need that, because they might connect to different datasources).
|
||||||
|
// the trick we do is, we wrap the callback in a "proxy",
|
||||||
|
// and in the proxy, the first thing is, we check if we are called from
|
||||||
|
// "our editor instance", and if not, we just return nothing. if yes,
|
||||||
|
// we call the completion-provider.
|
||||||
|
const filteringCompletionProvider: monacoTypes.languages.CompletionItemProvider = { |
||||||
|
...completionProvider, |
||||||
|
provideCompletionItems: (model, position, context, token) => { |
||||||
|
// if the model-id does not match, then this call is from a different editor-instance,
|
||||||
|
// not "our instance", so return nothing
|
||||||
|
if (editor.getModel()?.id !== model.id) { |
||||||
|
return { suggestions: [] }; |
||||||
|
} |
||||||
|
return completionProvider.provideCompletionItems(model, position, context, token); |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
const { dispose } = monaco.languages.registerCompletionItemProvider(LANG_ID, filteringCompletionProvider); |
||||||
|
|
||||||
|
autocompleteCleanupCallback.current = dispose; |
||||||
|
// this code makes the editor resize itself so that the content fits
|
||||||
|
// (it will grow taller when necessary)
|
||||||
|
// FIXME: maybe move this functionality into CodeEditor, like:
|
||||||
|
// <CodeEditor resizingMode="single-line"/>
|
||||||
|
const updateElementHeight = () => { |
||||||
|
const containerDiv = containerRef.current; |
||||||
|
if (containerDiv !== null) { |
||||||
|
const pixelHeight = editor.getContentHeight(); |
||||||
|
containerDiv.style.height = `${pixelHeight + EDITOR_HEIGHT_OFFSET}px`; |
||||||
|
containerDiv.style.width = '100%'; |
||||||
|
const pixelWidth = containerDiv.clientWidth; |
||||||
|
editor.layout({ width: pixelWidth, height: pixelHeight }); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
editor.onDidContentSizeChange(updateElementHeight); |
||||||
|
updateElementHeight(); |
||||||
|
|
||||||
|
// handle: shift + enter
|
||||||
|
// FIXME: maybe move this functionality into CodeEditor?
|
||||||
|
editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => { |
||||||
|
onRunQueryRef.current(editor.getValue()); |
||||||
|
}); |
||||||
|
|
||||||
|
editor.onDidFocusEditorText(() => { |
||||||
|
if (editor.getValue().trim() === '') { |
||||||
|
editor.trigger('', 'editor.action.triggerSuggest', {}); |
||||||
|
} |
||||||
|
}); |
||||||
|
}} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
// Default export for lazy load.
|
||||||
|
export default MonacoQueryField; |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
import React, { Suspense } from 'react'; |
||||||
|
|
||||||
|
import { Props } from './MonacoQueryFieldProps'; |
||||||
|
|
||||||
|
const Field = React.lazy(() => import(/* webpackChunkName: "loki-query-field" */ './MonacoQueryField')); |
||||||
|
|
||||||
|
export const MonacoQueryFieldLazy = (props: Props) => { |
||||||
|
return ( |
||||||
|
<Suspense fallback={null}> |
||||||
|
<Field {...props} /> |
||||||
|
</Suspense> |
||||||
|
); |
||||||
|
}; |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
import { HistoryItem } from '@grafana/data'; |
||||||
|
|
||||||
|
import type LanguageProvider from '../../LanguageProvider'; |
||||||
|
import { LokiQuery } from '../../types'; |
||||||
|
|
||||||
|
// we need to store this in a separate file,
|
||||||
|
// because we have an async-wrapper around,
|
||||||
|
// the react-component, and it needs the same
|
||||||
|
// props as the sync-component.
|
||||||
|
export type Props = { |
||||||
|
initialValue: string; |
||||||
|
languageProvider: LanguageProvider; |
||||||
|
history: Array<HistoryItem<LokiQuery>>; |
||||||
|
onRunQuery: (value: string) => void; |
||||||
|
onBlur: (value: string) => void; |
||||||
|
}; |
||||||
@ -0,0 +1,34 @@ |
|||||||
|
import React, { useRef } from 'react'; |
||||||
|
|
||||||
|
import { MonacoQueryFieldLazy } from './MonacoQueryFieldLazy'; |
||||||
|
import { Props as MonacoProps } from './MonacoQueryFieldProps'; |
||||||
|
|
||||||
|
type Props = Omit<MonacoProps, 'onRunQuery' | 'onBlur'> & { |
||||||
|
onChange: (query: string) => void; |
||||||
|
onRunQuery: () => void; |
||||||
|
runQueryOnBlur: boolean; |
||||||
|
}; |
||||||
|
|
||||||
|
export const MonacoQueryFieldWrapper = (props: Props) => { |
||||||
|
const lastRunValueRef = useRef<string | null>(null); |
||||||
|
const { runQueryOnBlur, onRunQuery, onChange, ...rest } = props; |
||||||
|
|
||||||
|
const handleRunQuery = (value: string) => { |
||||||
|
lastRunValueRef.current = value; |
||||||
|
onChange(value); |
||||||
|
onRunQuery(); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleBlur = (value: string) => { |
||||||
|
if (runQueryOnBlur) { |
||||||
|
// run handleRunQuery only if the current value is different from the last-time-executed value
|
||||||
|
if (value !== lastRunValueRef.current) { |
||||||
|
handleRunQuery(value); |
||||||
|
} |
||||||
|
} else { |
||||||
|
onChange(value); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
return <MonacoQueryFieldLazy onRunQuery={handleRunQuery} onBlur={handleBlur} {...rest} />; |
||||||
|
}; |
||||||
@ -0,0 +1,112 @@ |
|||||||
|
import { monacoTypes } from '@grafana/ui'; |
||||||
|
|
||||||
|
// this thing here is a workaround in a way.
|
||||||
|
// what we want to achieve, is that when the autocomplete-window
|
||||||
|
// opens, the "second, extra popup" with the extra help,
|
||||||
|
// also opens automatically.
|
||||||
|
// but there is no API to achieve it.
|
||||||
|
// the way to do it is to implement the `storageService`
|
||||||
|
// interface, and provide our custom implementation,
|
||||||
|
// which will default to `true` for the correct string-key.
|
||||||
|
// unfortunately, while the typescript-interface exists,
|
||||||
|
// it is not exported from monaco-editor,
|
||||||
|
// so we cannot rely on typescript to make sure
|
||||||
|
// we do it right. all we can do is to manually
|
||||||
|
// lookup the interface, and make sure we code our code right.
|
||||||
|
// our code is a "best effort" approach,
|
||||||
|
// i am not 100% how the `scope` and `target` things work,
|
||||||
|
// but so far it seems to work ok.
|
||||||
|
// i would use an another approach, if there was one available.
|
||||||
|
|
||||||
|
function makeStorageService() { |
||||||
|
// we need to return an object that fulfills this interface:
|
||||||
|
// https://github.com/microsoft/vscode/blob/ff1e16eebb93af79fd6d7af1356c4003a120c563/src/vs/platform/storage/common/storage.ts#L37
|
||||||
|
// unfortunately it is not export from monaco-editor
|
||||||
|
|
||||||
|
const strings = new Map<string, string>(); |
||||||
|
|
||||||
|
// we want this to be true by default
|
||||||
|
strings.set('expandSuggestionDocs', true.toString()); |
||||||
|
|
||||||
|
return { |
||||||
|
// we do not implement the on* handlers
|
||||||
|
onDidChangeValue: (data: unknown): void => undefined, |
||||||
|
onDidChangeTarget: (data: unknown): void => undefined, |
||||||
|
onWillSaveState: (data: unknown): void => undefined, |
||||||
|
|
||||||
|
get: (key: string, scope: unknown, fallbackValue?: string): string | undefined => { |
||||||
|
return strings.get(key) ?? fallbackValue; |
||||||
|
}, |
||||||
|
|
||||||
|
getBoolean: (key: string, scope: unknown, fallbackValue?: boolean): boolean | undefined => { |
||||||
|
const val = strings.get(key); |
||||||
|
if (val !== undefined) { |
||||||
|
// the interface docs say the value will be converted
|
||||||
|
// to a boolean but do not specify how, so we improvise
|
||||||
|
return val === 'true'; |
||||||
|
} else { |
||||||
|
return fallbackValue; |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
getNumber: (key: string, scope: unknown, fallbackValue?: number): number | undefined => { |
||||||
|
const val = strings.get(key); |
||||||
|
if (val !== undefined) { |
||||||
|
return parseInt(val, 10); |
||||||
|
} else { |
||||||
|
return fallbackValue; |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
store: ( |
||||||
|
key: string, |
||||||
|
value: string | boolean | number | undefined | null, |
||||||
|
scope: unknown, |
||||||
|
target: unknown |
||||||
|
): void => { |
||||||
|
// the interface docs say if the value is nullish, it should act as delete
|
||||||
|
if (value === null || value === undefined) { |
||||||
|
strings.delete(key); |
||||||
|
} else { |
||||||
|
strings.set(key, value.toString()); |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
remove: (key: string, scope: unknown): void => { |
||||||
|
strings.delete(key); |
||||||
|
}, |
||||||
|
|
||||||
|
keys: (scope: unknown, target: unknown): string[] => { |
||||||
|
return Array.from(strings.keys()); |
||||||
|
}, |
||||||
|
|
||||||
|
logStorage: (): void => { |
||||||
|
console.log('logStorage: not implemented'); |
||||||
|
}, |
||||||
|
|
||||||
|
migrate: (): Promise<void> => { |
||||||
|
// we do not implement this
|
||||||
|
return Promise.resolve(undefined); |
||||||
|
}, |
||||||
|
|
||||||
|
isNew: (scope: unknown): boolean => { |
||||||
|
// we create a new storage for every session, we do not persist it,
|
||||||
|
// so we return `true`.
|
||||||
|
return true; |
||||||
|
}, |
||||||
|
|
||||||
|
flush: (reason?: unknown): Promise<void> => { |
||||||
|
// we do not implement this
|
||||||
|
return Promise.resolve(undefined); |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
let overrideServices: monacoTypes.editor.IEditorOverrideServices = { |
||||||
|
storageService: makeStorageService(), |
||||||
|
}; |
||||||
|
|
||||||
|
export function getOverrideServices(): monacoTypes.editor.IEditorOverrideServices { |
||||||
|
// One instance of this for every query editor
|
||||||
|
return overrideServices; |
||||||
|
} |
||||||
@ -0,0 +1,90 @@ |
|||||||
|
import { HistoryItem } from '@grafana/data'; |
||||||
|
|
||||||
|
import LokiLanguageProvider from '../../../LanguageProvider'; |
||||||
|
import { LokiDatasource } from '../../../datasource'; |
||||||
|
import { createLokiDatasource } from '../../../mocks'; |
||||||
|
import { LokiQuery } from '../../../types'; |
||||||
|
|
||||||
|
import { CompletionDataProvider } from './CompletionDataProvider'; |
||||||
|
import { Label } from './situation'; |
||||||
|
|
||||||
|
const history = [ |
||||||
|
{ |
||||||
|
ts: 12345678, |
||||||
|
query: { |
||||||
|
refId: 'test-1', |
||||||
|
expr: '{test: unit}', |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
ts: 87654321, |
||||||
|
query: { |
||||||
|
refId: 'test-1', |
||||||
|
expr: '{unit: test}', |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
ts: 0, |
||||||
|
query: { |
||||||
|
refId: 'test-0', |
||||||
|
}, |
||||||
|
}, |
||||||
|
]; |
||||||
|
const labelKeys = ['place', 'source']; |
||||||
|
const labelValues = ['moon', 'luna']; |
||||||
|
const otherLabels: Label[] = [ |
||||||
|
{ |
||||||
|
name: 'place', |
||||||
|
value: 'luna', |
||||||
|
op: '=', |
||||||
|
}, |
||||||
|
]; |
||||||
|
const seriesLabels = { place: ['series', 'labels'], source: [], other: [] }; |
||||||
|
const parserAndLabelKeys = { |
||||||
|
extractedLabelKeys: ['extracted', 'label', 'keys'], |
||||||
|
hasJSON: true, |
||||||
|
hasLogfmt: false, |
||||||
|
}; |
||||||
|
|
||||||
|
describe('CompletionDataProvider', () => { |
||||||
|
let completionProvider: CompletionDataProvider, languageProvider: LokiLanguageProvider, datasource: LokiDatasource; |
||||||
|
beforeEach(() => { |
||||||
|
datasource = createLokiDatasource(); |
||||||
|
languageProvider = new LokiLanguageProvider(datasource); |
||||||
|
completionProvider = new CompletionDataProvider(languageProvider, history as Array<HistoryItem<LokiQuery>>); |
||||||
|
|
||||||
|
jest.spyOn(languageProvider, 'getLabelKeys').mockReturnValue(labelKeys); |
||||||
|
jest.spyOn(languageProvider, 'getLabelValues').mockResolvedValue(labelValues); |
||||||
|
jest.spyOn(languageProvider, 'getSeriesLabels').mockResolvedValue(seriesLabels); |
||||||
|
jest.spyOn(languageProvider, 'getParserAndLabelKeys').mockResolvedValue(parserAndLabelKeys); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Returns the expected history entries', () => { |
||||||
|
expect(completionProvider.getHistory()).toEqual(['{test: unit}', '{unit: test}']); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Returns the expected label names with no other labels', async () => { |
||||||
|
expect(await completionProvider.getLabelNames([])).toEqual(labelKeys); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Returns the expected label names with other labels', async () => { |
||||||
|
expect(await completionProvider.getLabelNames(otherLabels)).toEqual(['source', 'other']); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Returns the expected label values with no other labels', async () => { |
||||||
|
expect(await completionProvider.getLabelValues('label', [])).toEqual(labelValues); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Returns the expected label values with other labels', async () => { |
||||||
|
expect(await completionProvider.getLabelValues('place', otherLabels)).toEqual(['series', 'labels']); |
||||||
|
expect(await completionProvider.getLabelValues('other label', otherLabels)).toEqual([]); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Returns the expected parser and label keys', async () => { |
||||||
|
expect(await completionProvider.getParserAndLabelKeys([])).toEqual(parserAndLabelKeys); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Returns the expected series labels', async () => { |
||||||
|
expect(await completionProvider.getSeriesLabels([])).toEqual(seriesLabels); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,52 @@ |
|||||||
|
import { HistoryItem } from '@grafana/data'; |
||||||
|
import { escapeLabelValueInExactSelector } from 'app/plugins/datasource/prometheus/language_utils'; |
||||||
|
|
||||||
|
import LanguageProvider from '../../../LanguageProvider'; |
||||||
|
import { LokiQuery } from '../../../types'; |
||||||
|
|
||||||
|
import { Label } from './situation'; |
||||||
|
|
||||||
|
export class CompletionDataProvider { |
||||||
|
constructor(private languageProvider: LanguageProvider, private history: Array<HistoryItem<LokiQuery>> = []) {} |
||||||
|
|
||||||
|
private buildSelector(labels: Label[]): string { |
||||||
|
const allLabelTexts = labels.map( |
||||||
|
(label) => `${label.name}${label.op}"${escapeLabelValueInExactSelector(label.value)}"` |
||||||
|
); |
||||||
|
|
||||||
|
return `{${allLabelTexts.join(',')}}`; |
||||||
|
} |
||||||
|
|
||||||
|
getHistory() { |
||||||
|
return this.history.map((entry) => entry.query.expr).filter((expr) => expr !== undefined); |
||||||
|
} |
||||||
|
|
||||||
|
async getLabelNames(otherLabels: Label[] = []) { |
||||||
|
if (otherLabels.length === 0) { |
||||||
|
// if there is no filtering, we have to use a special endpoint
|
||||||
|
return this.languageProvider.getLabelKeys(); |
||||||
|
} |
||||||
|
const data = await this.getSeriesLabels(otherLabels); |
||||||
|
const possibleLabelNames = Object.keys(data); // all names from datasource
|
||||||
|
const usedLabelNames = new Set(otherLabels.map((l) => l.name)); // names used in the query
|
||||||
|
return possibleLabelNames.filter((label) => !usedLabelNames.has(label)); |
||||||
|
} |
||||||
|
|
||||||
|
async getLabelValues(labelName: string, otherLabels: Label[]) { |
||||||
|
if (otherLabels.length === 0) { |
||||||
|
// if there is no filtering, we have to use a special endpoint
|
||||||
|
return await this.languageProvider.getLabelValues(labelName); |
||||||
|
} |
||||||
|
|
||||||
|
const data = await this.getSeriesLabels(otherLabels); |
||||||
|
return data[labelName] ?? []; |
||||||
|
} |
||||||
|
|
||||||
|
async getParserAndLabelKeys(labels: Label[]) { |
||||||
|
return await this.languageProvider.getParserAndLabelKeys(this.buildSelector(labels)); |
||||||
|
} |
||||||
|
|
||||||
|
async getSeriesLabels(labels: Label[]) { |
||||||
|
return await this.languageProvider.getSeriesLabels(this.buildSelector(labels)).then((data) => data ?? {}); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
// This helper class is used to make typescript warn you when you miss a case-block in a switch statement.
|
||||||
|
// For example:
|
||||||
|
//
|
||||||
|
// const x:'A'|'B'|'C' = 'A';
|
||||||
|
//
|
||||||
|
// switch(x) {
|
||||||
|
// case 'A':
|
||||||
|
// // something
|
||||||
|
// case 'B':
|
||||||
|
// // something
|
||||||
|
// default:
|
||||||
|
// throw new NeverCaseError(x);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// TypeScript detect the missing case and display an error.
|
||||||
|
|
||||||
|
export class NeverCaseError extends Error { |
||||||
|
constructor(value: never) { |
||||||
|
super(`Unexpected case in switch statement: ${JSON.stringify(value)}`); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,297 @@ |
|||||||
|
import LokiLanguageProvider from '../../../LanguageProvider'; |
||||||
|
import { LokiDatasource } from '../../../datasource'; |
||||||
|
import { createLokiDatasource } from '../../../mocks'; |
||||||
|
|
||||||
|
import { CompletionDataProvider } from './CompletionDataProvider'; |
||||||
|
import { getCompletions } from './completions'; |
||||||
|
import { Label, Situation } from './situation'; |
||||||
|
|
||||||
|
const history = [ |
||||||
|
{ |
||||||
|
ts: 12345678, |
||||||
|
query: { |
||||||
|
refId: 'test-1', |
||||||
|
expr: '{test: unit}', |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
ts: 87654321, |
||||||
|
query: { |
||||||
|
refId: 'test-1', |
||||||
|
expr: '{test: unit}', |
||||||
|
}, |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
const labelNames = ['place', 'source']; |
||||||
|
const labelValues = ['moon', 'luna']; |
||||||
|
const extractedLabelKeys = ['extracted', 'label']; |
||||||
|
const otherLabels: Label[] = [ |
||||||
|
{ |
||||||
|
name: 'place', |
||||||
|
value: 'luna', |
||||||
|
op: '=', |
||||||
|
}, |
||||||
|
]; |
||||||
|
const afterSelectorCompletions = [ |
||||||
|
{ |
||||||
|
insertText: '|= "$0"', |
||||||
|
isSnippet: true, |
||||||
|
label: '|= ""', |
||||||
|
type: 'LINE_FILTER', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: '!= "$0"', |
||||||
|
isSnippet: true, |
||||||
|
label: '!= ""', |
||||||
|
type: 'LINE_FILTER', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: '|~ "$0"', |
||||||
|
isSnippet: true, |
||||||
|
label: '|~ ""', |
||||||
|
type: 'LINE_FILTER', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: '!~ "$0"', |
||||||
|
isSnippet: true, |
||||||
|
label: '!~ ""', |
||||||
|
type: 'LINE_FILTER', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: '', |
||||||
|
label: '// Placeholder for the detected parser', |
||||||
|
type: 'DETECTED_PARSER_PLACEHOLDER', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: '', |
||||||
|
label: '// Placeholder for logfmt or json', |
||||||
|
type: 'OPPOSITE_PARSER_PLACEHOLDER', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: 'pattern', |
||||||
|
label: 'pattern', |
||||||
|
type: 'PARSER', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: 'regexp', |
||||||
|
label: 'regexp', |
||||||
|
type: 'PARSER', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: 'unpack', |
||||||
|
label: 'unpack', |
||||||
|
type: 'PARSER', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: 'unwrap extracted', |
||||||
|
label: 'unwrap extracted (detected)', |
||||||
|
type: 'LINE_FILTER', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: 'unwrap label', |
||||||
|
label: 'unwrap label (detected)', |
||||||
|
type: 'LINE_FILTER', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: 'unwrap', |
||||||
|
label: 'unwrap', |
||||||
|
type: 'LINE_FILTER', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: 'line_format "{{.$0}}"', |
||||||
|
isSnippet: true, |
||||||
|
label: 'line_format', |
||||||
|
type: 'LINE_FORMAT', |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
function buildAfterSelectorCompletions( |
||||||
|
detectedParser: string, |
||||||
|
detectedParserType: string, |
||||||
|
otherParser: string, |
||||||
|
explanation = '(detected)' |
||||||
|
) { |
||||||
|
return afterSelectorCompletions.map((completion) => { |
||||||
|
if (completion.type === 'DETECTED_PARSER_PLACEHOLDER') { |
||||||
|
return { |
||||||
|
...completion, |
||||||
|
type: detectedParserType, |
||||||
|
label: `${detectedParser} ${explanation}`, |
||||||
|
insertText: detectedParser, |
||||||
|
}; |
||||||
|
} else if (completion.type === 'OPPOSITE_PARSER_PLACEHOLDER') { |
||||||
|
return { |
||||||
|
...completion, |
||||||
|
type: 'PARSER', |
||||||
|
label: otherParser, |
||||||
|
insertText: otherParser, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
return { ...completion }; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
describe('getCompletions', () => { |
||||||
|
let completionProvider: CompletionDataProvider, languageProvider: LokiLanguageProvider, datasource: LokiDatasource; |
||||||
|
beforeEach(() => { |
||||||
|
datasource = createLokiDatasource(); |
||||||
|
languageProvider = new LokiLanguageProvider(datasource); |
||||||
|
completionProvider = new CompletionDataProvider(languageProvider, history); |
||||||
|
|
||||||
|
jest.spyOn(completionProvider, 'getLabelNames').mockResolvedValue(labelNames); |
||||||
|
jest.spyOn(completionProvider, 'getLabelValues').mockResolvedValue(labelValues); |
||||||
|
jest.spyOn(completionProvider, 'getParserAndLabelKeys').mockResolvedValue({ |
||||||
|
extractedLabelKeys, |
||||||
|
hasJSON: false, |
||||||
|
hasLogfmt: false, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
test.each(['EMPTY', 'AT_ROOT'])(`Returns completion options when the situation is %s`, async (type) => { |
||||||
|
const situation = { type } as Situation; |
||||||
|
const completions = await getCompletions(situation, completionProvider); |
||||||
|
|
||||||
|
expect(completions).toHaveLength(25); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Returns completion options when the situation is IN_DURATION', async () => { |
||||||
|
const situation: Situation = { type: 'IN_DURATION' }; |
||||||
|
const completions = await getCompletions(situation, completionProvider); |
||||||
|
|
||||||
|
expect(completions).toEqual([ |
||||||
|
{ insertText: '$__interval', label: '$__interval', type: 'DURATION' }, |
||||||
|
{ insertText: '$__range', label: '$__range', type: 'DURATION' }, |
||||||
|
{ insertText: '1m', label: '1m', type: 'DURATION' }, |
||||||
|
{ insertText: '5m', label: '5m', type: 'DURATION' }, |
||||||
|
{ insertText: '10m', label: '10m', type: 'DURATION' }, |
||||||
|
{ insertText: '30m', label: '30m', type: 'DURATION' }, |
||||||
|
{ insertText: '1h', label: '1h', type: 'DURATION' }, |
||||||
|
{ insertText: '1d', label: '1d', type: 'DURATION' }, |
||||||
|
]); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Returns completion options when the situation is IN_GROUPING', async () => { |
||||||
|
const situation: Situation = { type: 'IN_GROUPING', otherLabels }; |
||||||
|
const completions = await getCompletions(situation, completionProvider); |
||||||
|
|
||||||
|
expect(completions).toEqual([ |
||||||
|
{ |
||||||
|
insertText: 'place', |
||||||
|
label: 'place', |
||||||
|
triggerOnInsert: false, |
||||||
|
type: 'LABEL_NAME', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: 'source', |
||||||
|
label: 'source', |
||||||
|
triggerOnInsert: false, |
||||||
|
type: 'LABEL_NAME', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: 'extracted', |
||||||
|
label: 'extracted (parsed)', |
||||||
|
triggerOnInsert: false, |
||||||
|
type: 'LABEL_NAME', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: 'label', |
||||||
|
label: 'label (parsed)', |
||||||
|
triggerOnInsert: false, |
||||||
|
type: 'LABEL_NAME', |
||||||
|
}, |
||||||
|
]); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Returns completion options when the situation is IN_LABEL_SELECTOR_NO_LABEL_NAME', async () => { |
||||||
|
const situation: Situation = { type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', otherLabels }; |
||||||
|
const completions = await getCompletions(situation, completionProvider); |
||||||
|
|
||||||
|
expect(completions).toEqual([ |
||||||
|
{ |
||||||
|
insertText: 'place=', |
||||||
|
label: 'place', |
||||||
|
triggerOnInsert: true, |
||||||
|
type: 'LABEL_NAME', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: 'source=', |
||||||
|
label: 'source', |
||||||
|
triggerOnInsert: true, |
||||||
|
type: 'LABEL_NAME', |
||||||
|
}, |
||||||
|
]); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Returns completion options when the situation is IN_LABEL_SELECTOR_WITH_LABEL_NAME', async () => { |
||||||
|
const situation: Situation = { |
||||||
|
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', |
||||||
|
otherLabels, |
||||||
|
labelName: '', |
||||||
|
betweenQuotes: false, |
||||||
|
}; |
||||||
|
let completions = await getCompletions(situation, completionProvider); |
||||||
|
|
||||||
|
expect(completions).toEqual([ |
||||||
|
{ |
||||||
|
insertText: '"moon"', |
||||||
|
label: 'moon', |
||||||
|
type: 'LABEL_VALUE', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: '"luna"', |
||||||
|
label: 'luna', |
||||||
|
type: 'LABEL_VALUE', |
||||||
|
}, |
||||||
|
]); |
||||||
|
|
||||||
|
completions = await getCompletions({ ...situation, betweenQuotes: true }, completionProvider); |
||||||
|
|
||||||
|
expect(completions).toEqual([ |
||||||
|
{ |
||||||
|
insertText: 'moon', |
||||||
|
label: 'moon', |
||||||
|
type: 'LABEL_VALUE', |
||||||
|
}, |
||||||
|
{ |
||||||
|
insertText: 'luna', |
||||||
|
label: 'luna', |
||||||
|
type: 'LABEL_VALUE', |
||||||
|
}, |
||||||
|
]); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Returns completion options when the situation is AFTER_SELECTOR and JSON parser', async () => { |
||||||
|
jest.spyOn(completionProvider, 'getParserAndLabelKeys').mockResolvedValue({ |
||||||
|
extractedLabelKeys, |
||||||
|
hasJSON: true, |
||||||
|
hasLogfmt: false, |
||||||
|
}); |
||||||
|
const situation: Situation = { type: 'AFTER_SELECTOR', labels: [], afterPipe: true }; |
||||||
|
const completions = await getCompletions(situation, completionProvider); |
||||||
|
|
||||||
|
const expected = buildAfterSelectorCompletions('json', 'PARSER', 'logfmt'); |
||||||
|
expect(completions).toEqual(expected); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Returns completion options when the situation is AFTER_SELECTOR and Logfmt parser', async () => { |
||||||
|
jest.spyOn(completionProvider, 'getParserAndLabelKeys').mockResolvedValue({ |
||||||
|
extractedLabelKeys, |
||||||
|
hasJSON: false, |
||||||
|
hasLogfmt: true, |
||||||
|
}); |
||||||
|
const situation: Situation = { type: 'AFTER_SELECTOR', labels: [], afterPipe: true }; |
||||||
|
const completions = await getCompletions(situation, completionProvider); |
||||||
|
|
||||||
|
const expected = buildAfterSelectorCompletions('logfmt', 'DURATION', 'json'); |
||||||
|
expect(completions).toEqual(expected); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Returns completion options when the situation is IN_AGGREGATION', async () => { |
||||||
|
const situation: Situation = { type: 'IN_AGGREGATION' }; |
||||||
|
const completions = await getCompletions(situation, completionProvider); |
||||||
|
|
||||||
|
expect(completions).toHaveLength(22); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,233 @@ |
|||||||
|
import { AGGREGATION_OPERATORS, RANGE_VEC_FUNCTIONS } from '../../../syntax'; |
||||||
|
|
||||||
|
import { CompletionDataProvider } from './CompletionDataProvider'; |
||||||
|
import { NeverCaseError } from './NeverCaseError'; |
||||||
|
import type { Situation, Label } from './situation'; |
||||||
|
|
||||||
|
export type CompletionType = |
||||||
|
| 'HISTORY' |
||||||
|
| 'FUNCTION' |
||||||
|
| 'DURATION' |
||||||
|
| 'LABEL_NAME' |
||||||
|
| 'LABEL_VALUE' |
||||||
|
| 'PATTERN' |
||||||
|
| 'PARSER' |
||||||
|
| 'LINE_FILTER' |
||||||
|
| 'LINE_FORMAT'; |
||||||
|
|
||||||
|
type Completion = { |
||||||
|
type: CompletionType; |
||||||
|
label: string; |
||||||
|
insertText: string; |
||||||
|
detail?: string; |
||||||
|
documentation?: string; |
||||||
|
triggerOnInsert?: boolean; |
||||||
|
isSnippet?: boolean; |
||||||
|
}; |
||||||
|
|
||||||
|
const LOG_COMPLETIONS: Completion[] = [ |
||||||
|
{ |
||||||
|
type: 'PATTERN', |
||||||
|
label: '{}', |
||||||
|
insertText: '{$0}', |
||||||
|
isSnippet: true, |
||||||
|
triggerOnInsert: true, |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
const AGGREGATION_COMPLETIONS: Completion[] = AGGREGATION_OPERATORS.map((f) => ({ |
||||||
|
type: 'FUNCTION', |
||||||
|
label: f.label, |
||||||
|
insertText: `${f.insertText ?? ''}($0)`, // i don't know what to do when this is nullish. it should not be.
|
||||||
|
isSnippet: true, |
||||||
|
triggerOnInsert: true, |
||||||
|
detail: f.detail, |
||||||
|
documentation: f.documentation, |
||||||
|
})); |
||||||
|
|
||||||
|
const FUNCTION_COMPLETIONS: Completion[] = RANGE_VEC_FUNCTIONS.map((f) => ({ |
||||||
|
type: 'FUNCTION', |
||||||
|
label: f.label, |
||||||
|
insertText: `${f.insertText ?? ''}({$0}[\\$__interval])`, // i don't know what to do when this is nullish. it should not be.
|
||||||
|
isSnippet: true, |
||||||
|
triggerOnInsert: true, |
||||||
|
detail: f.detail, |
||||||
|
documentation: f.documentation, |
||||||
|
})); |
||||||
|
|
||||||
|
const DURATION_COMPLETIONS: Completion[] = ['$__interval', '$__range', '1m', '5m', '10m', '30m', '1h', '1d'].map( |
||||||
|
(text) => ({ |
||||||
|
type: 'DURATION', |
||||||
|
label: text, |
||||||
|
insertText: text, |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
const LINE_FILTER_COMPLETIONS: Completion[] = ['|=', '!=', '|~', '!~'].map((item) => ({ |
||||||
|
type: 'LINE_FILTER', |
||||||
|
label: `${item} ""`, |
||||||
|
insertText: `${item} "$0"`, |
||||||
|
isSnippet: true, |
||||||
|
})); |
||||||
|
|
||||||
|
async function getAllHistoryCompletions(dataProvider: CompletionDataProvider): Promise<Completion[]> { |
||||||
|
const history = await dataProvider.getHistory(); |
||||||
|
|
||||||
|
return history.map((expr) => ({ |
||||||
|
type: 'HISTORY', |
||||||
|
label: expr, |
||||||
|
insertText: expr, |
||||||
|
})); |
||||||
|
} |
||||||
|
|
||||||
|
async function getLabelNamesForCompletions( |
||||||
|
suffix: string, |
||||||
|
triggerOnInsert: boolean, |
||||||
|
addExtractedLabels: boolean, |
||||||
|
otherLabels: Label[], |
||||||
|
dataProvider: CompletionDataProvider |
||||||
|
): Promise<Completion[]> { |
||||||
|
const labelNames = await dataProvider.getLabelNames(otherLabels); |
||||||
|
const result: Completion[] = labelNames.map((text) => ({ |
||||||
|
type: 'LABEL_NAME', |
||||||
|
label: text, |
||||||
|
insertText: `${text}${suffix}`, |
||||||
|
triggerOnInsert, |
||||||
|
})); |
||||||
|
|
||||||
|
if (addExtractedLabels) { |
||||||
|
const { extractedLabelKeys } = await dataProvider.getParserAndLabelKeys(otherLabels); |
||||||
|
extractedLabelKeys.forEach((key) => { |
||||||
|
result.push({ |
||||||
|
type: 'LABEL_NAME', |
||||||
|
label: `${key} (parsed)`, |
||||||
|
insertText: `${key}${suffix}`, |
||||||
|
triggerOnInsert, |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
async function getLabelNamesForSelectorCompletions( |
||||||
|
otherLabels: Label[], |
||||||
|
dataProvider: CompletionDataProvider |
||||||
|
): Promise<Completion[]> { |
||||||
|
return getLabelNamesForCompletions('=', true, false, otherLabels, dataProvider); |
||||||
|
} |
||||||
|
|
||||||
|
async function getInGroupingCompletions( |
||||||
|
otherLabels: Label[], |
||||||
|
dataProvider: CompletionDataProvider |
||||||
|
): Promise<Completion[]> { |
||||||
|
return getLabelNamesForCompletions('', false, true, otherLabels, dataProvider); |
||||||
|
} |
||||||
|
|
||||||
|
async function getAfterSelectorCompletions( |
||||||
|
labels: Label[], |
||||||
|
afterPipe: boolean, |
||||||
|
dataProvider: CompletionDataProvider |
||||||
|
): Promise<Completion[]> { |
||||||
|
const { extractedLabelKeys, hasJSON, hasLogfmt } = await dataProvider.getParserAndLabelKeys(labels); |
||||||
|
const allParsers = new Set(['json', 'logfmt', 'pattern', 'regexp', 'unpack']); |
||||||
|
const completions: Completion[] = []; |
||||||
|
const prefix = afterPipe ? '' : '| '; |
||||||
|
const hasLevelInExtractedLabels = extractedLabelKeys.some((key) => key === 'level'); |
||||||
|
if (hasJSON) { |
||||||
|
allParsers.delete('json'); |
||||||
|
const explanation = hasLevelInExtractedLabels ? 'use to get log-levels in the histogram' : 'detected'; |
||||||
|
completions.push({ |
||||||
|
type: 'PARSER', |
||||||
|
label: `json (${explanation})`, |
||||||
|
insertText: `${prefix}json`, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
if (hasLogfmt) { |
||||||
|
allParsers.delete('logfmt'); |
||||||
|
const explanation = hasLevelInExtractedLabels ? 'get detected levels in the histogram' : 'detected'; |
||||||
|
completions.push({ |
||||||
|
type: 'DURATION', |
||||||
|
label: `logfmt (${explanation})`, |
||||||
|
insertText: `${prefix}logfmt`, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
const remainingParsers = Array.from(allParsers).sort(); |
||||||
|
remainingParsers.forEach((parser) => { |
||||||
|
completions.push({ |
||||||
|
type: 'PARSER', |
||||||
|
label: parser, |
||||||
|
insertText: `${prefix}${parser}`, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
extractedLabelKeys.forEach((key) => { |
||||||
|
completions.push({ |
||||||
|
type: 'LINE_FILTER', |
||||||
|
label: `unwrap ${key} (detected)`, |
||||||
|
insertText: `${prefix}unwrap ${key}`, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
completions.push({ |
||||||
|
type: 'LINE_FILTER', |
||||||
|
label: 'unwrap', |
||||||
|
insertText: `${prefix}unwrap`, |
||||||
|
}); |
||||||
|
|
||||||
|
completions.push({ |
||||||
|
type: 'LINE_FORMAT', |
||||||
|
label: 'line_format', |
||||||
|
insertText: `${prefix}line_format "{{.$0}}"`, |
||||||
|
isSnippet: true, |
||||||
|
}); |
||||||
|
|
||||||
|
return [...LINE_FILTER_COMPLETIONS, ...completions]; |
||||||
|
} |
||||||
|
|
||||||
|
async function getLabelValuesForMetricCompletions( |
||||||
|
labelName: string, |
||||||
|
betweenQuotes: boolean, |
||||||
|
otherLabels: Label[], |
||||||
|
dataProvider: CompletionDataProvider |
||||||
|
): Promise<Completion[]> { |
||||||
|
const values = await dataProvider.getLabelValues(labelName, otherLabels); |
||||||
|
return values.map((text) => ({ |
||||||
|
type: 'LABEL_VALUE', |
||||||
|
label: text, |
||||||
|
insertText: betweenQuotes ? text : `"${text}"`, |
||||||
|
})); |
||||||
|
} |
||||||
|
|
||||||
|
export async function getCompletions( |
||||||
|
situation: Situation, |
||||||
|
dataProvider: CompletionDataProvider |
||||||
|
): Promise<Completion[]> { |
||||||
|
switch (situation.type) { |
||||||
|
case 'EMPTY': |
||||||
|
case 'AT_ROOT': |
||||||
|
const historyCompletions = await getAllHistoryCompletions(dataProvider); |
||||||
|
return [...historyCompletions, ...LOG_COMPLETIONS, ...AGGREGATION_COMPLETIONS, ...FUNCTION_COMPLETIONS]; |
||||||
|
case 'IN_DURATION': |
||||||
|
return DURATION_COMPLETIONS; |
||||||
|
case 'IN_GROUPING': |
||||||
|
return getInGroupingCompletions(situation.otherLabels, dataProvider); |
||||||
|
case 'IN_LABEL_SELECTOR_NO_LABEL_NAME': |
||||||
|
return getLabelNamesForSelectorCompletions(situation.otherLabels, dataProvider); |
||||||
|
case 'IN_LABEL_SELECTOR_WITH_LABEL_NAME': |
||||||
|
return getLabelValuesForMetricCompletions( |
||||||
|
situation.labelName, |
||||||
|
situation.betweenQuotes, |
||||||
|
situation.otherLabels, |
||||||
|
dataProvider |
||||||
|
); |
||||||
|
case 'AFTER_SELECTOR': |
||||||
|
return getAfterSelectorCompletions(situation.labels, situation.afterPipe, dataProvider); |
||||||
|
case 'IN_AGGREGATION': |
||||||
|
return [...FUNCTION_COMPLETIONS, ...AGGREGATION_COMPLETIONS]; |
||||||
|
default: |
||||||
|
throw new NeverCaseError(situation); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,112 @@ |
|||||||
|
import type { Monaco, monacoTypes } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { CompletionDataProvider } from './CompletionDataProvider'; |
||||||
|
import { NeverCaseError } from './NeverCaseError'; |
||||||
|
import { getCompletions, CompletionType } from './completions'; |
||||||
|
import { getSituation } from './situation'; |
||||||
|
|
||||||
|
// from: monacoTypes.languages.CompletionItemInsertTextRule.InsertAsSnippet
|
||||||
|
const INSERT_AS_SNIPPET_ENUM_VALUE = 4; |
||||||
|
|
||||||
|
export function getSuggestOptions(): monacoTypes.editor.ISuggestOptions { |
||||||
|
return { |
||||||
|
// monaco-editor sometimes provides suggestions automatically, i am not
|
||||||
|
// sure based on what, seems to be by analyzing the words already
|
||||||
|
// written.
|
||||||
|
// to try it out:
|
||||||
|
// - enter `go_goroutines{job~`
|
||||||
|
// - have the cursor at the end of the string
|
||||||
|
// - press ctrl-enter
|
||||||
|
// - you will get two suggestions
|
||||||
|
// those were not provided by grafana, they are offered automatically.
|
||||||
|
// i want to remove those. the only way i found is:
|
||||||
|
// - every suggestion-item has a `kind` attribute,
|
||||||
|
// that controls the icon to the left of the suggestion.
|
||||||
|
// - items auto-generated by monaco have `kind` set to `text`.
|
||||||
|
// - we make sure grafana-provided suggestions do not have `kind` set to `text`.
|
||||||
|
// - and then we tell monaco not to show suggestions of kind `text`
|
||||||
|
showWords: false, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): monacoTypes.languages.CompletionItemKind { |
||||||
|
switch (type) { |
||||||
|
case 'DURATION': |
||||||
|
return monaco.languages.CompletionItemKind.Unit; |
||||||
|
case 'FUNCTION': |
||||||
|
return monaco.languages.CompletionItemKind.Variable; |
||||||
|
case 'HISTORY': |
||||||
|
return monaco.languages.CompletionItemKind.Snippet; |
||||||
|
case 'LABEL_NAME': |
||||||
|
return monaco.languages.CompletionItemKind.Enum; |
||||||
|
case 'LABEL_VALUE': |
||||||
|
return monaco.languages.CompletionItemKind.EnumMember; |
||||||
|
case 'PATTERN': |
||||||
|
return monaco.languages.CompletionItemKind.Constructor; |
||||||
|
case 'PARSER': |
||||||
|
return monaco.languages.CompletionItemKind.Class; |
||||||
|
case 'LINE_FILTER': |
||||||
|
return monaco.languages.CompletionItemKind.TypeParameter; |
||||||
|
case 'LINE_FORMAT': |
||||||
|
return monaco.languages.CompletionItemKind.Event; |
||||||
|
default: |
||||||
|
throw new NeverCaseError(type); |
||||||
|
} |
||||||
|
} |
||||||
|
export function getCompletionProvider( |
||||||
|
monaco: Monaco, |
||||||
|
dataProvider: CompletionDataProvider |
||||||
|
): monacoTypes.languages.CompletionItemProvider { |
||||||
|
const provideCompletionItems = ( |
||||||
|
model: monacoTypes.editor.ITextModel, |
||||||
|
position: monacoTypes.Position |
||||||
|
): monacoTypes.languages.ProviderResult<monacoTypes.languages.CompletionList> => { |
||||||
|
const word = model.getWordAtPosition(position); |
||||||
|
const range = |
||||||
|
word != null |
||||||
|
? monaco.Range.lift({ |
||||||
|
startLineNumber: position.lineNumber, |
||||||
|
endLineNumber: position.lineNumber, |
||||||
|
startColumn: word.startColumn, |
||||||
|
endColumn: word.endColumn, |
||||||
|
}) |
||||||
|
: monaco.Range.fromPositions(position); |
||||||
|
// documentation says `position` will be "adjusted" in `getOffsetAt`
|
||||||
|
// i don't know what that means, to be sure i clone it
|
||||||
|
const positionClone = { |
||||||
|
column: position.column, |
||||||
|
lineNumber: position.lineNumber, |
||||||
|
}; |
||||||
|
const offset = model.getOffsetAt(positionClone); |
||||||
|
const situation = getSituation(model.getValue(), offset); |
||||||
|
const completionsPromise = situation != null ? getCompletions(situation, dataProvider) : Promise.resolve([]); |
||||||
|
return completionsPromise.then((items) => { |
||||||
|
// monaco by default alphabetically orders the items.
|
||||||
|
// to stop it, we use a number-as-string sortkey,
|
||||||
|
// so that monaco keeps the order we use
|
||||||
|
const maxIndexDigits = items.length.toString().length; |
||||||
|
const suggestions: monacoTypes.languages.CompletionItem[] = items.map((item, index) => ({ |
||||||
|
kind: getMonacoCompletionItemKind(item.type, monaco), |
||||||
|
label: item.label, |
||||||
|
insertText: item.insertText, |
||||||
|
insertTextRules: item.isSnippet ? INSERT_AS_SNIPPET_ENUM_VALUE : undefined, |
||||||
|
detail: item.detail, |
||||||
|
documentation: item.documentation, |
||||||
|
sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have
|
||||||
|
range, |
||||||
|
command: item.triggerOnInsert |
||||||
|
? { |
||||||
|
id: 'editor.action.triggerSuggest', |
||||||
|
title: '', |
||||||
|
} |
||||||
|
: undefined, |
||||||
|
})); |
||||||
|
return { suggestions }; |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
return { |
||||||
|
triggerCharacters: ['{', ',', '[', '(', '=', '~', ' ', '"', '|'], |
||||||
|
provideCompletionItems, |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,195 @@ |
|||||||
|
import { getSituation, Situation } from './situation'; |
||||||
|
|
||||||
|
// we use the `^` character as the cursor-marker in the string.
|
||||||
|
function assertSituation(situation: string, expectedSituation: Situation | null) { |
||||||
|
// first we find the cursor-position
|
||||||
|
const pos = situation.indexOf('^'); |
||||||
|
if (pos === -1) { |
||||||
|
throw new Error('cursor missing'); |
||||||
|
} |
||||||
|
|
||||||
|
// we remove the cursor-marker from the string
|
||||||
|
const text = situation.replace('^', ''); |
||||||
|
|
||||||
|
// sanity check, make sure no more cursor-markers remain
|
||||||
|
if (text.indexOf('^') !== -1) { |
||||||
|
throw new Error('multiple cursors'); |
||||||
|
} |
||||||
|
|
||||||
|
const result = getSituation(text, pos); |
||||||
|
|
||||||
|
if (expectedSituation === null) { |
||||||
|
expect(result).toStrictEqual(null); |
||||||
|
} else { |
||||||
|
expect(result).toMatchObject(expectedSituation); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
describe('situation', () => { |
||||||
|
it('handles things', () => { |
||||||
|
assertSituation('^', { |
||||||
|
type: 'EMPTY', |
||||||
|
}); |
||||||
|
|
||||||
|
assertSituation('s^', { |
||||||
|
type: 'AT_ROOT', |
||||||
|
}); |
||||||
|
|
||||||
|
assertSituation('{level="info"} ^', { |
||||||
|
type: 'AFTER_SELECTOR', |
||||||
|
afterPipe: false, |
||||||
|
labels: [{ name: 'level', value: 'info', op: '=' }], |
||||||
|
}); |
||||||
|
|
||||||
|
// should not trigger AFTER_SELECTOR before the selector
|
||||||
|
assertSituation('^ {level="info"}', null); |
||||||
|
|
||||||
|
// check for an error we had during the implementation
|
||||||
|
assertSituation('{level="info" ^', null); |
||||||
|
|
||||||
|
assertSituation('{level="info"} | json ^', { |
||||||
|
type: 'AFTER_SELECTOR', |
||||||
|
afterPipe: false, |
||||||
|
labels: [{ name: 'level', value: 'info', op: '=' }], |
||||||
|
}); |
||||||
|
|
||||||
|
assertSituation('{level="info"} | json | ^', { |
||||||
|
type: 'AFTER_SELECTOR', |
||||||
|
afterPipe: true, |
||||||
|
labels: [{ name: 'level', value: 'info', op: '=' }], |
||||||
|
}); |
||||||
|
|
||||||
|
assertSituation('count_over_time({level="info"}^[10s])', { |
||||||
|
type: 'AFTER_SELECTOR', |
||||||
|
afterPipe: false, |
||||||
|
labels: [{ name: 'level', value: 'info', op: '=' }], |
||||||
|
}); |
||||||
|
|
||||||
|
// should not trigger AFTER_SELECTOR before the selector
|
||||||
|
assertSituation('count_over_time(^{level="info"}[10s])', null); |
||||||
|
|
||||||
|
// should work even when the query is half-complete
|
||||||
|
assertSituation('count_over_time({level="info"}^)', { |
||||||
|
type: 'AFTER_SELECTOR', |
||||||
|
afterPipe: false, |
||||||
|
labels: [{ name: 'level', value: 'info', op: '=' }], |
||||||
|
}); |
||||||
|
|
||||||
|
/* |
||||||
|
Currently failing, reason unknown |
||||||
|
assertSituation('sum(^)', { |
||||||
|
type: 'IN_AGGREGATION', |
||||||
|
});*/ |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles label names', () => { |
||||||
|
assertSituation('{^}', { |
||||||
|
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', |
||||||
|
otherLabels: [], |
||||||
|
}); |
||||||
|
|
||||||
|
assertSituation('sum(count_over_time({level="info"})) by (^)', { |
||||||
|
type: 'IN_GROUPING', |
||||||
|
otherLabels: [{ name: 'level', value: 'info', op: '=' }], |
||||||
|
}); |
||||||
|
|
||||||
|
assertSituation('sum by (^) (count_over_time({level="info"}))', { |
||||||
|
type: 'IN_GROUPING', |
||||||
|
otherLabels: [{ name: 'level', value: 'info', op: '=' }], |
||||||
|
}); |
||||||
|
|
||||||
|
assertSituation('{one="val1",two!="val2",three=~"val3",four!~"val4",^}', { |
||||||
|
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', |
||||||
|
otherLabels: [ |
||||||
|
{ name: 'one', value: 'val1', op: '=' }, |
||||||
|
{ name: 'two', value: 'val2', op: '!=' }, |
||||||
|
{ name: 'three', value: 'val3', op: '=~' }, |
||||||
|
{ name: 'four', value: 'val4', op: '!~' }, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
assertSituation('{one="val1",^}', { |
||||||
|
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', |
||||||
|
otherLabels: [{ name: 'one', value: 'val1', op: '=' }], |
||||||
|
}); |
||||||
|
|
||||||
|
// double-quoted label-values with escape
|
||||||
|
assertSituation('{one="val\\"1",^}', { |
||||||
|
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', |
||||||
|
otherLabels: [{ name: 'one', value: 'val"1', op: '=' }], |
||||||
|
}); |
||||||
|
|
||||||
|
// backticked label-values with escape (the escape should not be interpreted)
|
||||||
|
assertSituation('{one=`val\\"1`,^}', { |
||||||
|
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', |
||||||
|
otherLabels: [{ name: 'one', value: 'val\\"1', op: '=' }], |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles label values', () => { |
||||||
|
assertSituation('{job=^}', { |
||||||
|
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', |
||||||
|
labelName: 'job', |
||||||
|
betweenQuotes: false, |
||||||
|
otherLabels: [], |
||||||
|
}); |
||||||
|
|
||||||
|
assertSituation('{job!=^}', { |
||||||
|
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', |
||||||
|
labelName: 'job', |
||||||
|
betweenQuotes: false, |
||||||
|
otherLabels: [], |
||||||
|
}); |
||||||
|
|
||||||
|
assertSituation('{job=~^}', { |
||||||
|
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', |
||||||
|
labelName: 'job', |
||||||
|
betweenQuotes: false, |
||||||
|
otherLabels: [], |
||||||
|
}); |
||||||
|
|
||||||
|
assertSituation('{job!~^}', { |
||||||
|
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', |
||||||
|
labelName: 'job', |
||||||
|
betweenQuotes: false, |
||||||
|
otherLabels: [], |
||||||
|
}); |
||||||
|
|
||||||
|
assertSituation('{job=^,host="h1"}', { |
||||||
|
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', |
||||||
|
labelName: 'job', |
||||||
|
betweenQuotes: false, |
||||||
|
otherLabels: [{ name: 'host', value: 'h1', op: '=' }], |
||||||
|
}); |
||||||
|
|
||||||
|
assertSituation('{job="j1",host="^"}', { |
||||||
|
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', |
||||||
|
labelName: 'host', |
||||||
|
betweenQuotes: true, |
||||||
|
otherLabels: [{ name: 'job', value: 'j1', op: '=' }], |
||||||
|
}); |
||||||
|
|
||||||
|
assertSituation('{job="j1"^}', null); |
||||||
|
assertSituation('{job="j1" ^ }', null); |
||||||
|
assertSituation('{job="j1" ^ , }', null); |
||||||
|
|
||||||
|
assertSituation('{job=^,host="h1"}', { |
||||||
|
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', |
||||||
|
labelName: 'job', |
||||||
|
betweenQuotes: false, |
||||||
|
otherLabels: [{ name: 'host', value: 'h1', op: '=' }], |
||||||
|
}); |
||||||
|
|
||||||
|
assertSituation('{one="val1",two!="val2",three=^,four=~"val4",five!~"val5"}', { |
||||||
|
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', |
||||||
|
labelName: 'three', |
||||||
|
betweenQuotes: false, |
||||||
|
otherLabels: [ |
||||||
|
{ name: 'one', value: 'val1', op: '=' }, |
||||||
|
{ name: 'two', value: 'val2', op: '!=' }, |
||||||
|
{ name: 'four', value: 'val4', op: '=~' }, |
||||||
|
{ name: 'five', value: 'val5', op: '!~' }, |
||||||
|
], |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,523 @@ |
|||||||
|
import type { Tree, SyntaxNode } from '@lezer/common'; |
||||||
|
|
||||||
|
import { |
||||||
|
parser, |
||||||
|
VectorAggregationExpr, |
||||||
|
String, |
||||||
|
Selector, |
||||||
|
RangeAggregationExpr, |
||||||
|
Range, |
||||||
|
PipelineExpr, |
||||||
|
PipelineStage, |
||||||
|
Matchers, |
||||||
|
Matcher, |
||||||
|
LogQL, |
||||||
|
LogRangeExpr, |
||||||
|
LogExpr, |
||||||
|
Identifier, |
||||||
|
Grouping, |
||||||
|
Expr, |
||||||
|
} from '@grafana/lezer-logql'; |
||||||
|
|
||||||
|
type Direction = 'parent' | 'firstChild' | 'lastChild' | 'nextSibling'; |
||||||
|
type NodeType = number; |
||||||
|
|
||||||
|
type Path = Array<[Direction, NodeType]>; |
||||||
|
|
||||||
|
function move(node: SyntaxNode, direction: Direction): SyntaxNode | null { |
||||||
|
return node[direction]; |
||||||
|
} |
||||||
|
|
||||||
|
function walk(node: SyntaxNode, path: Path): SyntaxNode | null { |
||||||
|
let current: SyntaxNode | null = node; |
||||||
|
for (const [direction, expectedNode] of path) { |
||||||
|
current = move(current, direction); |
||||||
|
if (current === null) { |
||||||
|
// we could not move in the direction, we stop
|
||||||
|
return null; |
||||||
|
} |
||||||
|
if (current.type.id !== expectedNode) { |
||||||
|
// the reached node has wrong type, we stop
|
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
return current; |
||||||
|
} |
||||||
|
|
||||||
|
function getNodeText(node: SyntaxNode, text: string): string { |
||||||
|
return text.slice(node.from, node.to); |
||||||
|
} |
||||||
|
|
||||||
|
function parseStringLiteral(text: string): string { |
||||||
|
// If it is a string-literal, it is inside quotes of some kind
|
||||||
|
const inside = text.slice(1, text.length - 1); |
||||||
|
|
||||||
|
// Very simple un-escaping:
|
||||||
|
|
||||||
|
// Double quotes
|
||||||
|
if (text.startsWith('"') && text.endsWith('"')) { |
||||||
|
// NOTE: this is not 100% perfect, we only unescape the double-quote,
|
||||||
|
// there might be other characters too
|
||||||
|
return inside.replace(/\\"/, '"'); |
||||||
|
} |
||||||
|
|
||||||
|
// Single quotes
|
||||||
|
if (text.startsWith("'") && text.endsWith("'")) { |
||||||
|
// NOTE: this is not 100% perfect, we only unescape the single-quote,
|
||||||
|
// there might be other characters too
|
||||||
|
return inside.replace(/\\'/, "'"); |
||||||
|
} |
||||||
|
|
||||||
|
// Backticks
|
||||||
|
if (text.startsWith('`') && text.endsWith('`')) { |
||||||
|
return inside; |
||||||
|
} |
||||||
|
|
||||||
|
throw new Error(`Invalid string literal: ${text}`); |
||||||
|
} |
||||||
|
|
||||||
|
export type LabelOperator = '=' | '!=' | '=~' | '!~'; |
||||||
|
|
||||||
|
export type Label = { |
||||||
|
name: string; |
||||||
|
value: string; |
||||||
|
op: LabelOperator; |
||||||
|
}; |
||||||
|
|
||||||
|
export type Situation = |
||||||
|
| { |
||||||
|
type: 'EMPTY'; |
||||||
|
} |
||||||
|
| { |
||||||
|
type: 'AT_ROOT'; |
||||||
|
} |
||||||
|
| { |
||||||
|
type: 'IN_DURATION'; |
||||||
|
} |
||||||
|
| { |
||||||
|
type: 'IN_AGGREGATION'; |
||||||
|
} |
||||||
|
| { |
||||||
|
type: 'IN_GROUPING'; |
||||||
|
otherLabels: Label[]; |
||||||
|
} |
||||||
|
| { |
||||||
|
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME'; |
||||||
|
otherLabels: Label[]; |
||||||
|
} |
||||||
|
| { |
||||||
|
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME'; |
||||||
|
labelName: string; |
||||||
|
betweenQuotes: boolean; |
||||||
|
otherLabels: Label[]; |
||||||
|
} |
||||||
|
| { |
||||||
|
type: 'AFTER_SELECTOR'; |
||||||
|
afterPipe: boolean; |
||||||
|
labels: Label[]; |
||||||
|
}; |
||||||
|
|
||||||
|
type Resolver = { |
||||||
|
path: NodeType[]; |
||||||
|
fun: (node: SyntaxNode, text: string, pos: number) => Situation | null; |
||||||
|
}; |
||||||
|
|
||||||
|
function isPathMatch(resolverPath: NodeType[], cursorPath: number[]): boolean { |
||||||
|
return resolverPath.every((item, index) => item === cursorPath[index]); |
||||||
|
} |
||||||
|
|
||||||
|
const ERROR_NODE_ID = 0; |
||||||
|
|
||||||
|
const RESOLVERS: Resolver[] = [ |
||||||
|
{ |
||||||
|
path: [Selector], |
||||||
|
fun: resolveSelector, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: [LogQL], |
||||||
|
fun: resolveTopLevel, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: [String, Matcher], |
||||||
|
fun: resolveMatcher, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: [Grouping], |
||||||
|
fun: resolveLabelsForGrouping, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: [LogRangeExpr], |
||||||
|
fun: resolveLogRange, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: [ERROR_NODE_ID, Matcher], |
||||||
|
fun: resolveMatcher, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: [ERROR_NODE_ID, Range], |
||||||
|
fun: resolveDurations, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: [ERROR_NODE_ID, LogRangeExpr], |
||||||
|
fun: resolveLogRangeFromError, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: [ERROR_NODE_ID, VectorAggregationExpr], |
||||||
|
fun: () => ({ type: 'IN_AGGREGATION' }), |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: [ERROR_NODE_ID, PipelineStage, PipelineExpr], |
||||||
|
fun: resolvePipeError, |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
const LABEL_OP_MAP = new Map<string, LabelOperator>([ |
||||||
|
['Eq', '='], |
||||||
|
['Re', '=~'], |
||||||
|
['Neq', '!='], |
||||||
|
['Nre', '!~'], |
||||||
|
]); |
||||||
|
|
||||||
|
function getLabelOp(opNode: SyntaxNode): LabelOperator | null { |
||||||
|
return LABEL_OP_MAP.get(opNode.name) ?? null; |
||||||
|
} |
||||||
|
|
||||||
|
function getLabel(matcherNode: SyntaxNode, text: string): Label | null { |
||||||
|
if (matcherNode.type.id !== Matcher) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const nameNode = walk(matcherNode, [['firstChild', Identifier]]); |
||||||
|
|
||||||
|
if (nameNode === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const opNode = nameNode.nextSibling; |
||||||
|
if (opNode === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const op = getLabelOp(opNode); |
||||||
|
if (op === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const valueNode = walk(matcherNode, [['lastChild', String]]); |
||||||
|
|
||||||
|
if (valueNode === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const name = getNodeText(nameNode, text); |
||||||
|
const value = parseStringLiteral(getNodeText(valueNode, text)); |
||||||
|
|
||||||
|
return { name, value, op }; |
||||||
|
} |
||||||
|
|
||||||
|
function getLabels(selectorNode: SyntaxNode, text: string): Label[] { |
||||||
|
if (selectorNode.type.id !== Selector) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
let listNode: SyntaxNode | null = walk(selectorNode, [['firstChild', Matchers]]); |
||||||
|
|
||||||
|
const labels: Label[] = []; |
||||||
|
|
||||||
|
while (listNode !== null) { |
||||||
|
const matcherNode = walk(listNode, [['lastChild', Matcher]]); |
||||||
|
if (matcherNode === null) { |
||||||
|
// unexpected, we stop
|
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
const label = getLabel(matcherNode, text); |
||||||
|
if (label !== null) { |
||||||
|
labels.push(label); |
||||||
|
} |
||||||
|
|
||||||
|
// there might be more labels
|
||||||
|
listNode = walk(listNode, [['firstChild', Matchers]]); |
||||||
|
} |
||||||
|
|
||||||
|
// our labels-list is last-first, so we reverse it
|
||||||
|
labels.reverse(); |
||||||
|
|
||||||
|
return labels; |
||||||
|
} |
||||||
|
|
||||||
|
function resolvePipeError(node: SyntaxNode, text: string, pos: number): Situation | null { |
||||||
|
// for example `{level="info"} |`
|
||||||
|
const exprNode = walk(node, [ |
||||||
|
['parent', PipelineStage], |
||||||
|
['parent', PipelineExpr], |
||||||
|
]); |
||||||
|
|
||||||
|
if (exprNode === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const { parent } = exprNode; |
||||||
|
|
||||||
|
if (parent === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
if (parent.type.id === LogExpr || parent.type.id === LogRangeExpr) { |
||||||
|
return resolveLogOrLogRange(parent, text, pos, true); |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
function resolveLabelsForGrouping(node: SyntaxNode, text: string, pos: number): Situation | null { |
||||||
|
const aggrExpNode = walk(node, [['parent', VectorAggregationExpr]]); |
||||||
|
if (aggrExpNode === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
const bodyNode = aggrExpNode.getChild('MetricExpr'); |
||||||
|
if (bodyNode === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const selectorNode = walk(bodyNode, [ |
||||||
|
['firstChild', RangeAggregationExpr], |
||||||
|
['lastChild', LogRangeExpr], |
||||||
|
['firstChild', Selector], |
||||||
|
]); |
||||||
|
|
||||||
|
if (selectorNode === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const otherLabels = getLabels(selectorNode, text); |
||||||
|
|
||||||
|
return { |
||||||
|
type: 'IN_GROUPING', |
||||||
|
otherLabels, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function resolveMatcher(node: SyntaxNode, text: string, pos: number): Situation | null { |
||||||
|
// we can arrive here for two reasons. `node` is either:
|
||||||
|
// - a StringNode (like in `{job="^"}`)
|
||||||
|
// - or an error node (like in `{job=^}`)
|
||||||
|
const inStringNode = !node.type.isError; |
||||||
|
|
||||||
|
const parent = walk(node, [['parent', Matcher]]); |
||||||
|
if (parent === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const labelNameNode = walk(parent, [['firstChild', Identifier]]); |
||||||
|
if (labelNameNode === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const labelName = getNodeText(labelNameNode, text); |
||||||
|
|
||||||
|
// now we need to go up, to the parent of Matcher,
|
||||||
|
// there can be one or many `Matchers` parents, we have
|
||||||
|
// to go through all of them
|
||||||
|
|
||||||
|
const firstListNode = walk(parent, [['parent', Matchers]]); |
||||||
|
if (firstListNode === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
let listNode = firstListNode; |
||||||
|
|
||||||
|
// we keep going through the parent-nodes as long as they are Matchers.
|
||||||
|
// as soon as we reach Selector, we stop
|
||||||
|
let selectorNode: SyntaxNode | null = null; |
||||||
|
while (selectorNode === null) { |
||||||
|
const parent = listNode.parent; |
||||||
|
if (parent === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
switch (parent.type.id) { |
||||||
|
case Matchers: |
||||||
|
//we keep looping
|
||||||
|
listNode = parent; |
||||||
|
continue; |
||||||
|
case Selector: |
||||||
|
// we reached the end, we can stop the loop
|
||||||
|
selectorNode = parent; |
||||||
|
continue; |
||||||
|
default: |
||||||
|
// we reached some other node, we stop
|
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// now we need to find the other names
|
||||||
|
const allLabels = getLabels(selectorNode, text); |
||||||
|
|
||||||
|
// we need to remove "our" label from all-labels, if it is in there
|
||||||
|
const otherLabels = allLabels.filter((label) => label.name !== labelName); |
||||||
|
|
||||||
|
return { |
||||||
|
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', |
||||||
|
labelName, |
||||||
|
betweenQuotes: inStringNode, |
||||||
|
otherLabels, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function resolveTopLevel(node: SyntaxNode, text: string, pos: number): Situation | null { |
||||||
|
// we try a couply specific paths here.
|
||||||
|
// `{x="y"}` situation, with the cursor at the end
|
||||||
|
|
||||||
|
const logExprNode = walk(node, [ |
||||||
|
['lastChild', Expr], |
||||||
|
['lastChild', LogExpr], |
||||||
|
]); |
||||||
|
|
||||||
|
if (logExprNode != null) { |
||||||
|
return resolveLogOrLogRange(logExprNode, text, pos, false); |
||||||
|
} |
||||||
|
|
||||||
|
// `s` situation, with the cursor at the end.
|
||||||
|
// (basically, user enters a non-special characters as first
|
||||||
|
// character in query field)
|
||||||
|
const idNode = walk(node, [ |
||||||
|
['firstChild', ERROR_NODE_ID], |
||||||
|
['firstChild', Identifier], |
||||||
|
]); |
||||||
|
|
||||||
|
if (idNode != null) { |
||||||
|
return { |
||||||
|
type: 'AT_ROOT', |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// no patterns match
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
function resolveDurations(node: SyntaxNode, text: string, pos: number): Situation { |
||||||
|
return { |
||||||
|
type: 'IN_DURATION', |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function resolveLogRange(node: SyntaxNode, text: string, pos: number): Situation | null { |
||||||
|
return resolveLogOrLogRange(node, text, pos, false); |
||||||
|
} |
||||||
|
|
||||||
|
function resolveLogRangeFromError(node: SyntaxNode, text: string, pos: number): Situation | null { |
||||||
|
const parent = walk(node, [['parent', LogRangeExpr]]); |
||||||
|
if (parent === null) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return resolveLogOrLogRange(parent, text, pos, false); |
||||||
|
} |
||||||
|
|
||||||
|
function resolveLogOrLogRange(node: SyntaxNode, text: string, pos: number, afterPipe: boolean): Situation | null { |
||||||
|
// here the `node` is either a LogExpr or a LogRangeExpr
|
||||||
|
// we want to handle the case where we are next to a selector
|
||||||
|
const selectorNode = walk(node, [['firstChild', Selector]]); |
||||||
|
|
||||||
|
// we check that the selector is before the cursor, not after it
|
||||||
|
if (selectorNode != null && selectorNode.to <= pos) { |
||||||
|
const labels = getLabels(selectorNode, text); |
||||||
|
return { |
||||||
|
type: 'AFTER_SELECTOR', |
||||||
|
afterPipe, |
||||||
|
labels, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
function resolveSelector(node: SyntaxNode, text: string, pos: number): Situation | null { |
||||||
|
// for example `{^}`
|
||||||
|
|
||||||
|
// false positive:
|
||||||
|
// `{a="1"^}`
|
||||||
|
const child = walk(node, [['firstChild', Matchers]]); |
||||||
|
if (child !== null) { |
||||||
|
// means the label-matching part contains at least one label already.
|
||||||
|
//
|
||||||
|
// in this case, we will need to have a `,` character at the end,
|
||||||
|
// to be able to suggest adding the next label.
|
||||||
|
// the area between the end-of-the-child-node and the cursor-pos
|
||||||
|
// must contain a `,` in this case.
|
||||||
|
const textToCheck = text.slice(child.to, pos); |
||||||
|
|
||||||
|
if (!textToCheck.includes(',')) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const otherLabels = getLabels(node, text); |
||||||
|
|
||||||
|
return { |
||||||
|
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME', |
||||||
|
otherLabels, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// we find the first error-node in the tree that is at the cursor-position.
|
||||||
|
// NOTE: this might be too slow, might need to optimize it
|
||||||
|
// (ideas: we do not need to go into every subtree, based on from/to)
|
||||||
|
// also, only go to places that are in the sub-tree of the node found
|
||||||
|
// by default by lezer. problem is, `next()` will go upward too,
|
||||||
|
// and we do not want to go higher than our node
|
||||||
|
function getErrorNode(tree: Tree, text: string, cursorPos: number): SyntaxNode | null { |
||||||
|
// sometimes the cursor is a couple spaces after the end of the expression.
|
||||||
|
// to account for this situation, we "move" the cursor position back,
|
||||||
|
// so that there are no spaces between the end-of-expression and the cursor
|
||||||
|
const trimRightTextLen = text.trimEnd().length; |
||||||
|
const pos = trimRightTextLen < cursorPos ? trimRightTextLen : cursorPos; |
||||||
|
const cur = tree.cursorAt(pos); |
||||||
|
do { |
||||||
|
if (cur.from === pos && cur.to === pos) { |
||||||
|
const { node } = cur; |
||||||
|
if (node.type.isError) { |
||||||
|
return node; |
||||||
|
} |
||||||
|
} |
||||||
|
} while (cur.next()); |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
export function getSituation(text: string, pos: number): Situation | null { |
||||||
|
// there is a special case when we are at the start of writing text,
|
||||||
|
// so we handle that case first
|
||||||
|
|
||||||
|
if (text === '') { |
||||||
|
return { |
||||||
|
type: 'EMPTY', |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
const tree = parser.parse(text); |
||||||
|
|
||||||
|
// if the tree contains error, it is very probable that
|
||||||
|
// our node is one of those error nodes.
|
||||||
|
// also, if there are errors, the node lezer finds us,
|
||||||
|
// might not be the best node.
|
||||||
|
// so first we check if there is an error node at the cursor position
|
||||||
|
const maybeErrorNode = getErrorNode(tree, text, pos); |
||||||
|
|
||||||
|
const cur = maybeErrorNode != null ? maybeErrorNode.cursor() : tree.cursorAt(pos); |
||||||
|
|
||||||
|
const currentNode = cur.node; |
||||||
|
|
||||||
|
const ids = [cur.type.id]; |
||||||
|
while (cur.parent()) { |
||||||
|
ids.push(cur.type.id); |
||||||
|
} |
||||||
|
|
||||||
|
for (let resolver of RESOLVERS) { |
||||||
|
if (isPathMatch(resolver.path, ids)) { |
||||||
|
return resolver.fun(currentNode, text, pos); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
@ -0,0 +1,43 @@ |
|||||||
|
import { escapeLabelValueInExactSelector, escapeLabelValueInRegexSelector } from './languageUtils'; |
||||||
|
|
||||||
|
describe('escapeLabelValueInExactSelector()', () => { |
||||||
|
it('handles newline characters', () => { |
||||||
|
expect(escapeLabelValueInExactSelector('t\nes\nt')).toBe('t\\nes\\nt'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles backslash characters', () => { |
||||||
|
expect(escapeLabelValueInExactSelector('t\\es\\t')).toBe('t\\\\es\\\\t'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles double-quote characters', () => { |
||||||
|
expect(escapeLabelValueInExactSelector('t"es"t')).toBe('t\\"es\\"t'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles all together', () => { |
||||||
|
expect(escapeLabelValueInExactSelector('t\\e"st\nl\nab"e\\l')).toBe('t\\\\e\\"st\\nl\\nab\\"e\\\\l'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('escapeLabelValueInRegexSelector()', () => { |
||||||
|
it('handles newline characters', () => { |
||||||
|
expect(escapeLabelValueInRegexSelector('t\nes\nt')).toBe('t\\nes\\nt'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles backslash characters', () => { |
||||||
|
expect(escapeLabelValueInRegexSelector('t\\es\\t')).toBe('t\\\\\\\\es\\\\\\\\t'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles double-quote characters', () => { |
||||||
|
expect(escapeLabelValueInRegexSelector('t"es"t')).toBe('t\\"es\\"t'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles regex-meaningful characters', () => { |
||||||
|
expect(escapeLabelValueInRegexSelector('t+es$t')).toBe('t\\\\+es\\\\$t'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles all together', () => { |
||||||
|
expect(escapeLabelValueInRegexSelector('t\\e"s+t\nl\n$ab"e\\l')).toBe( |
||||||
|
't\\\\\\\\e\\"s\\\\+t\\nl\\n\\\\$ab\\"e\\\\\\\\l' |
||||||
|
); |
||||||
|
}); |
||||||
|
}); |
||||||
Loading…
Reference in new issue