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