mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Tempo - Replace slate with monaco editor in search tags field (#61168)
* Recreate the tempo search tags field with monaco editor instead of slate * Remove test file no longer needed
This commit is contained in:
parent
80e7f54166
commit
62633ba4a7
@ -1,33 +1,19 @@
|
||||
import { css } from '@emotion/css';
|
||||
import Prism from 'prismjs';
|
||||
import React, { useCallback, useState, useEffect, useMemo } from 'react';
|
||||
import { Node } from 'slate';
|
||||
|
||||
import { GrafanaTheme2, isValidGoDuration, SelectableValue, toOption } from '@grafana/data';
|
||||
import { FetchError, getTemplateSrv, isFetchError, TemplateSrv } from '@grafana/runtime';
|
||||
import {
|
||||
InlineFieldRow,
|
||||
InlineField,
|
||||
Input,
|
||||
QueryField,
|
||||
SlatePrism,
|
||||
BracesPlugin,
|
||||
TypeaheadInput,
|
||||
TypeaheadOutput,
|
||||
Alert,
|
||||
useStyles2,
|
||||
fuzzyMatch,
|
||||
Select,
|
||||
} from '@grafana/ui';
|
||||
import { InlineFieldRow, InlineField, Input, Alert, useStyles2, fuzzyMatch, Select } from '@grafana/ui';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||
import { dispatch } from 'app/store/store';
|
||||
|
||||
import { DEFAULT_LIMIT, TempoDatasource } from '../datasource';
|
||||
import TempoLanguageProvider from '../language_provider';
|
||||
import { tokenizer } from '../syntax';
|
||||
import { TempoQuery } from '../types';
|
||||
|
||||
import { TagsField } from './TagsField/TagsField';
|
||||
|
||||
interface Props {
|
||||
datasource: TempoDatasource;
|
||||
query: TempoQuery;
|
||||
@ -36,22 +22,11 @@ interface Props {
|
||||
onRunQuery: () => void;
|
||||
}
|
||||
|
||||
const PRISM_LANGUAGE = 'tempo';
|
||||
const durationPlaceholder = 'e.g. 1.2s, 100ms';
|
||||
const plugins = [
|
||||
BracesPlugin(),
|
||||
SlatePrism({
|
||||
onlyIn: (node: Node) => node.object === 'block' && node.type === 'code_block',
|
||||
getSyntax: () => PRISM_LANGUAGE,
|
||||
}),
|
||||
];
|
||||
|
||||
Prism.languages[PRISM_LANGUAGE] = tokenizer;
|
||||
|
||||
const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]);
|
||||
const [hasSyntaxLoaded, setHasSyntaxLoaded] = useState(false);
|
||||
const [serviceOptions, setServiceOptions] = useState<Array<SelectableValue<string>>>();
|
||||
const [spanOptions, setSpanOptions] = useState<Array<SelectableValue<string>>>();
|
||||
const [error, setError] = useState<Error | FetchError | null>(null);
|
||||
@ -111,35 +86,6 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props
|
||||
fetchOptions();
|
||||
}, [languageProvider, loadOptions, query.serviceName, query.spanName]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
await languageProvider.start();
|
||||
setHasSyntaxLoaded(true);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
dispatch(notifyApp(createErrorNotification('Error', error)));
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchTags();
|
||||
}, [languageProvider]);
|
||||
|
||||
const onTypeahead = useCallback(
|
||||
async (typeahead: TypeaheadInput): Promise<TypeaheadOutput> => {
|
||||
return await languageProvider.provideCompletionItems(typeahead);
|
||||
},
|
||||
[languageProvider]
|
||||
);
|
||||
|
||||
const cleanText = useCallback((text: string) => {
|
||||
const splittedText = text.split(/\s+(?=([^"]*"[^"]*")*[^"]*$)/g);
|
||||
if (splittedText.length > 1) {
|
||||
return splittedText[splittedText.length - 1];
|
||||
}
|
||||
return text;
|
||||
}, []);
|
||||
|
||||
const onKeyDown = (keyEvent: React.KeyboardEvent) => {
|
||||
if (keyEvent.key === 'Enter' && (keyEvent.shiftKey || keyEvent.ctrlKey)) {
|
||||
onRunQuery();
|
||||
@ -219,17 +165,12 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Tags" labelWidth={14} grow tooltip="Values should be in logfmt.">
|
||||
<QueryField
|
||||
additionalPlugins={plugins}
|
||||
query={query.search}
|
||||
onTypeahead={onTypeahead}
|
||||
onBlur={onBlur}
|
||||
onChange={handleOnChange}
|
||||
cleanText={cleanText}
|
||||
<TagsField
|
||||
placeholder="http.status_code=200 error=true"
|
||||
onRunQuery={onRunQuery}
|
||||
syntaxLoaded={hasSyntaxLoaded}
|
||||
portalOrigin="tempo"
|
||||
value={query.search || ''}
|
||||
onChange={handleOnChange}
|
||||
onBlur={onBlur}
|
||||
datasource={datasource}
|
||||
/>
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
|
@ -0,0 +1,187 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { CodeEditor, Monaco, monacoTypes, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { createErrorNotification } from '../../../../../core/copy/appNotification';
|
||||
import { notifyApp } from '../../../../../core/reducers/appNotification';
|
||||
import { dispatch } from '../../../../../store/store';
|
||||
import { TempoDatasource } from '../../datasource';
|
||||
|
||||
import { CompletionProvider } from './autocomplete';
|
||||
import { languageDefinition } from './syntax';
|
||||
|
||||
interface Props {
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
onBlur?: () => void;
|
||||
datasource: TempoDatasource;
|
||||
}
|
||||
|
||||
export function TagsField(props: Props) {
|
||||
const { onChange, onBlur, placeholder } = props;
|
||||
const setupAutocompleteFn = useAutocomplete(props.datasource);
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme, placeholder);
|
||||
|
||||
return (
|
||||
<CodeEditor
|
||||
value={props.value}
|
||||
language={langId}
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
containerStyles={styles.queryField}
|
||||
monacoOptions={{
|
||||
folding: false,
|
||||
fontSize: 14,
|
||||
lineNumbers: 'off',
|
||||
overviewRulerLanes: 0,
|
||||
renderLineHighlight: 'none',
|
||||
scrollbar: {
|
||||
vertical: 'hidden',
|
||||
verticalScrollbarSize: 8, // used as "padding-right"
|
||||
horizontal: 'hidden',
|
||||
horizontalScrollbarSize: 0,
|
||||
},
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
}}
|
||||
onBeforeEditorMount={ensureTraceQL}
|
||||
onEditorDidMount={(editor, monaco) => {
|
||||
setupAutocompleteFn(editor, monaco);
|
||||
setupPlaceholder(editor, monaco, styles);
|
||||
setupAutoSize(editor);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function setupPlaceholder(editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco, styles: EditorStyles) {
|
||||
const placeholderDecorators = [
|
||||
{
|
||||
range: new monaco.Range(1, 1, 1, 1),
|
||||
options: {
|
||||
className: styles.placeholder, // The placeholder text is in styles.placeholder
|
||||
isWholeLine: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let decorators: string[] = [];
|
||||
|
||||
const checkDecorators = (): void => {
|
||||
const model = editor.getModel();
|
||||
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDecorators = model.getValueLength() === 0 ? placeholderDecorators : [];
|
||||
decorators = model.deltaDecorations(decorators, newDecorators);
|
||||
};
|
||||
|
||||
checkDecorators();
|
||||
editor.onDidChangeModelContent(checkDecorators);
|
||||
}
|
||||
|
||||
function setupAutoSize(editor: monacoTypes.editor.IStandaloneCodeEditor) {
|
||||
const container = editor.getDomNode();
|
||||
const updateHeight = () => {
|
||||
if (container) {
|
||||
const contentHeight = Math.min(1000, editor.getContentHeight());
|
||||
const width = parseInt(container.style.width, 10);
|
||||
container.style.width = `${width}px`;
|
||||
container.style.height = `${contentHeight}px`;
|
||||
editor.layout({ width, height: contentHeight });
|
||||
}
|
||||
};
|
||||
editor.onDidContentSizeChange(updateHeight);
|
||||
updateHeight();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that returns function that will set up monaco autocomplete for the label selector
|
||||
* @param datasource
|
||||
*/
|
||||
function useAutocomplete(datasource: TempoDatasource) {
|
||||
// We need the provider ref so we can pass it the label/values data later. This is because we run the call for the
|
||||
// values here but there is additional setup needed for the provider later on. We could run the getSeries() in the
|
||||
// returned function but that is run after the monaco is mounted so would delay the request a bit when it does not
|
||||
// need to.
|
||||
const providerRef = useRef<CompletionProvider>(
|
||||
new CompletionProvider({ languageProvider: datasource.languageProvider })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
await datasource.languageProvider.start();
|
||||
const tags = datasource.languageProvider.getTags();
|
||||
|
||||
if (tags) {
|
||||
providerRef.current.setTags(tags);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
dispatch(notifyApp(createErrorNotification('Error', error)));
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchTags();
|
||||
}, [datasource]);
|
||||
|
||||
const autocompleteDisposeFun = useRef<(() => void) | null>(null);
|
||||
useEffect(() => {
|
||||
// when we unmount, we unregister the autocomplete-function, if it was registered
|
||||
return () => {
|
||||
autocompleteDisposeFun.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// This should be run in monaco onEditorDidMount
|
||||
return (editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => {
|
||||
providerRef.current.editor = editor;
|
||||
providerRef.current.monaco = monaco;
|
||||
|
||||
const { dispose } = monaco.languages.registerCompletionItemProvider(langId, providerRef.current);
|
||||
autocompleteDisposeFun.current = dispose;
|
||||
};
|
||||
}
|
||||
|
||||
// we must only run the setup code once
|
||||
let setupDone = false;
|
||||
const langId = 'tagsfield';
|
||||
|
||||
function ensureTraceQL(monaco: Monaco) {
|
||||
if (!setupDone) {
|
||||
setupDone = true;
|
||||
const { aliases, extensions, mimetypes, def } = languageDefinition;
|
||||
monaco.languages.register({ id: langId, aliases, extensions, mimetypes });
|
||||
monaco.languages.setMonarchTokensProvider(langId, def.language);
|
||||
monaco.languages.setLanguageConfiguration(langId, def.languageConfiguration);
|
||||
}
|
||||
}
|
||||
|
||||
interface EditorStyles {
|
||||
placeholder: string;
|
||||
queryField: string;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, placeholder: string): EditorStyles => {
|
||||
return {
|
||||
queryField: css`
|
||||
border-radius: ${theme.shape.borderRadius()};
|
||||
border: 1px solid ${theme.components.input.borderColor};
|
||||
flex: 1;
|
||||
`,
|
||||
placeholder: css`
|
||||
::after {
|
||||
content: '${placeholder}';
|
||||
font-family: ${theme.typography.fontFamilyMonospace};
|
||||
opacity: 0.3;
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
@ -0,0 +1,255 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import type { Monaco, monacoTypes } from '@grafana/ui';
|
||||
|
||||
import TempoLanguageProvider from '../../language_provider';
|
||||
|
||||
interface Props {
|
||||
languageProvider: TempoLanguageProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class that implements CompletionItemProvider interface and allows us to provide suggestion for the Monaco
|
||||
* autocomplete system.
|
||||
*/
|
||||
export class CompletionProvider implements monacoTypes.languages.CompletionItemProvider {
|
||||
languageProvider: TempoLanguageProvider;
|
||||
|
||||
constructor(props: Props) {
|
||||
this.languageProvider = props.languageProvider;
|
||||
}
|
||||
|
||||
triggerCharacters = ['=', ' '];
|
||||
|
||||
// We set these directly and ae required for the provider to function.
|
||||
monaco: Monaco | undefined;
|
||||
editor: monacoTypes.editor.IStandaloneCodeEditor | undefined;
|
||||
|
||||
private tags: { [tag: string]: Set<string> } = {};
|
||||
private cachedValues: { [key: string]: Array<SelectableValue<string>> } = {};
|
||||
|
||||
provideCompletionItems(
|
||||
model: monacoTypes.editor.ITextModel,
|
||||
position: monacoTypes.Position
|
||||
): monacoTypes.languages.ProviderResult<monacoTypes.languages.CompletionList> {
|
||||
// Should not happen, this should not be called before it is initialized
|
||||
if (!(this.monaco && this.editor)) {
|
||||
throw new Error('provideCompletionItems called before CompletionProvider was initialized');
|
||||
}
|
||||
|
||||
// if the model-id does not match, then this call is from a different editor-instance,
|
||||
// not "our instance", so return nothing
|
||||
if (this.editor.getModel()?.id !== model.id) {
|
||||
return { suggestions: [] };
|
||||
}
|
||||
|
||||
const { range, offset } = getRangeAndOffset(this.monaco, model, position);
|
||||
const situation = this.getSituation(model.getValue(), offset);
|
||||
const completionItems = this.getCompletions(situation);
|
||||
|
||||
return completionItems.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) => {
|
||||
const suggestion: monacoTypes.languages.CompletionItem = {
|
||||
kind: getMonacoCompletionItemKind(item.type, this.monaco!),
|
||||
label: item.label,
|
||||
insertText: item.insertText,
|
||||
sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have
|
||||
range,
|
||||
};
|
||||
return suggestion;
|
||||
});
|
||||
return { suggestions };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* We expect the tags list data directly from the request and assign it an empty set here.
|
||||
*/
|
||||
setTags(tags: string[]) {
|
||||
tags.forEach((t) => (this.tags[t] = new Set<string>()));
|
||||
}
|
||||
|
||||
private async getTagValues(tagName: string): Promise<Array<SelectableValue<string>>> {
|
||||
let tagValues: Array<SelectableValue<string>>;
|
||||
|
||||
if (this.cachedValues.hasOwnProperty(tagName)) {
|
||||
tagValues = this.cachedValues[tagName];
|
||||
} else {
|
||||
tagValues = await this.languageProvider.getOptions(tagName);
|
||||
this.cachedValues[tagName] = tagValues;
|
||||
}
|
||||
return tagValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggestion based on the situation we are in like whether we should suggest tag names or values.
|
||||
* @param situation
|
||||
* @private
|
||||
*/
|
||||
private async getCompletions(situation: Situation): Promise<Completion[]> {
|
||||
if (!Object.keys(this.tags).length) {
|
||||
return [];
|
||||
}
|
||||
switch (situation.type) {
|
||||
// Not really sure what would make sense to suggest in this case so just leave it
|
||||
case 'UNKNOWN': {
|
||||
return [];
|
||||
}
|
||||
case 'EMPTY': {
|
||||
return this.getTagsCompletions();
|
||||
}
|
||||
case 'IN_NAME':
|
||||
return this.getTagsCompletions();
|
||||
case 'IN_VALUE':
|
||||
const tagValues = await this.getTagValues(situation.tagName);
|
||||
const items: Completion[] = [];
|
||||
|
||||
const getInsertionText = (val: SelectableValue<string>): string => `"${val.label}"`;
|
||||
|
||||
tagValues.forEach((val) => {
|
||||
if (val?.label) {
|
||||
items.push({
|
||||
label: val.label,
|
||||
insertText: getInsertionText(val),
|
||||
type: 'TAG_VALUE',
|
||||
});
|
||||
}
|
||||
});
|
||||
return items;
|
||||
default:
|
||||
throw new Error(`Unexpected situation ${situation}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getTagsCompletions(): Completion[] {
|
||||
return Object.keys(this.tags)
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'accent' }))
|
||||
.map((key) => ({
|
||||
label: key,
|
||||
insertText: key,
|
||||
type: 'TAG_NAME',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Figure out where is the cursor and what kind of suggestions are appropriate.
|
||||
* @param text
|
||||
* @param offset
|
||||
*/
|
||||
private getSituation(text: string, offset: number): Situation {
|
||||
if (text === '' || offset === 0 || text[text.length - 1] === ' ') {
|
||||
return {
|
||||
type: 'EMPTY',
|
||||
};
|
||||
}
|
||||
|
||||
const textUntilCaret = text.substring(0, offset);
|
||||
|
||||
const regex = /(?<key>[^= ]+)(?<equals>=)?(?<value>([^ "]+)|"([^"]*)")?/;
|
||||
const matches = textUntilCaret.match(new RegExp(regex, 'g'));
|
||||
|
||||
if (matches?.length) {
|
||||
const last = matches[matches.length - 1];
|
||||
const lastMatched = last.match(regex);
|
||||
if (lastMatched) {
|
||||
const key = lastMatched.groups?.key;
|
||||
const equals = lastMatched.groups?.equals;
|
||||
|
||||
if (!key) {
|
||||
return {
|
||||
type: 'EMPTY',
|
||||
};
|
||||
}
|
||||
|
||||
if (!equals) {
|
||||
return {
|
||||
type: 'IN_NAME',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'IN_VALUE',
|
||||
tagName: key,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'EMPTY',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item kind which is used for icon next to the suggestion.
|
||||
* @param type
|
||||
* @param monaco
|
||||
*/
|
||||
function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): monacoTypes.languages.CompletionItemKind {
|
||||
switch (type) {
|
||||
case 'TAG_NAME':
|
||||
return monaco.languages.CompletionItemKind.Enum;
|
||||
case 'KEYWORD':
|
||||
return monaco.languages.CompletionItemKind.Keyword;
|
||||
case 'OPERATOR':
|
||||
return monaco.languages.CompletionItemKind.Operator;
|
||||
case 'TAG_VALUE':
|
||||
return monaco.languages.CompletionItemKind.EnumMember;
|
||||
case 'SCOPE':
|
||||
return monaco.languages.CompletionItemKind.Class;
|
||||
default:
|
||||
throw new Error(`Unexpected CompletionType: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type CompletionType = 'TAG_NAME' | 'TAG_VALUE' | 'KEYWORD' | 'OPERATOR' | 'SCOPE';
|
||||
type Completion = {
|
||||
type: CompletionType;
|
||||
label: string;
|
||||
insertText: string;
|
||||
};
|
||||
|
||||
export type Tag = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type Situation =
|
||||
| {
|
||||
type: 'UNKNOWN';
|
||||
}
|
||||
| {
|
||||
type: 'EMPTY';
|
||||
}
|
||||
| {
|
||||
type: 'IN_NAME';
|
||||
}
|
||||
| {
|
||||
type: 'IN_VALUE';
|
||||
tagName: string;
|
||||
};
|
||||
|
||||
function getRangeAndOffset(monaco: Monaco, model: monacoTypes.editor.ITextModel, position: monacoTypes.Position) {
|
||||
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` so we clone it here just for sure.
|
||||
const positionClone = {
|
||||
column: position.column,
|
||||
lineNumber: position.lineNumber,
|
||||
};
|
||||
|
||||
const offset = model.getOffsetAt(positionClone);
|
||||
return { offset, range };
|
||||
}
|
@ -0,0 +1,124 @@
|
||||
import type { languages } from 'monaco-editor';
|
||||
|
||||
export const languageConfiguration: languages.LanguageConfiguration = {
|
||||
// the default separators except `@$`
|
||||
wordPattern: /(-?\d*\.\d\w*)|([^`~!#%^&*()\-=+\[{\]}\\|;:'",.<>\/?\s]+)/g,
|
||||
brackets: [
|
||||
['{', '}'],
|
||||
['(', ')'],
|
||||
],
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
],
|
||||
surroundingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
],
|
||||
folding: {},
|
||||
};
|
||||
|
||||
const operators = ['='];
|
||||
|
||||
export const language: languages.IMonarchLanguage = {
|
||||
ignoreCase: false,
|
||||
defaultToken: '',
|
||||
tokenPostfix: '.tagsfield',
|
||||
|
||||
operators,
|
||||
|
||||
// we include these common regular expressions
|
||||
symbols: /[=><!~?:&|+\-*\/^%]+/,
|
||||
escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
|
||||
digits: /\d+(_+\d+)*/,
|
||||
octaldigits: /[0-7]+(_+[0-7]+)*/,
|
||||
binarydigits: /[0-1]+(_+[0-1]+)*/,
|
||||
hexdigits: /[[0-9a-fA-F]+(_+[0-9a-fA-F]+)*/,
|
||||
integersuffix: /(ll|LL|u|U|l|L)?(ll|LL|u|U|l|L)?/,
|
||||
floatsuffix: /[fFlL]?/,
|
||||
|
||||
tokenizer: {
|
||||
root: [
|
||||
// labels
|
||||
[/[a-z_.][\w./_-]*(?=\s*(=|!=|>|<|>=|<=|=~|!~))/, 'tag'],
|
||||
|
||||
// all keywords have the same color
|
||||
[
|
||||
/[a-zA-Z_.]\w*/,
|
||||
{
|
||||
cases: {
|
||||
'@default': 'identifier',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// strings
|
||||
[/"([^"\\]|\\.)*$/, 'string.invalid'], // non-teminated string
|
||||
[/'([^'\\]|\\.)*$/, 'string.invalid'], // non-teminated string
|
||||
[/"/, 'string', '@string_double'],
|
||||
[/'/, 'string', '@string_single'],
|
||||
|
||||
// whitespace
|
||||
{ include: '@whitespace' },
|
||||
|
||||
// delimiters and operators
|
||||
[/[{}()\[\]]/, '@brackets'],
|
||||
[/[<>](?!@symbols)/, '@brackets'],
|
||||
[
|
||||
/@symbols/,
|
||||
{
|
||||
cases: {
|
||||
'@operators': 'delimiter',
|
||||
'@default': '',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// numbers
|
||||
[/\d+/, 'number'],
|
||||
[/\d*\d+[eE]([\-+]?\d+)?(@floatsuffix)/, 'number.float'],
|
||||
[/\d*\.\d+([eE][\-+]?\d+)?(@floatsuffix)/, 'number.float'],
|
||||
[/0[xX][0-9a-fA-F']*[0-9a-fA-F](@integersuffix)/, 'number.hex'],
|
||||
[/0[0-7']*[0-7](@integersuffix)/, 'number.octal'],
|
||||
[/0[bB][0-1']*[0-1](@integersuffix)/, 'number.binary'],
|
||||
[/\d[\d']*\d(@integersuffix)/, 'number'],
|
||||
[/\d(@integersuffix)/, 'number'],
|
||||
],
|
||||
|
||||
string_double: [
|
||||
[/[^\\"]+/, 'string'],
|
||||
[/@escapes/, 'string.escape'],
|
||||
[/\\./, 'string.escape.invalid'],
|
||||
[/"/, 'string', '@pop'],
|
||||
],
|
||||
|
||||
string_single: [
|
||||
[/[^\\']+/, 'string'],
|
||||
[/@escapes/, 'string.escape'],
|
||||
[/\\./, 'string.escape.invalid'],
|
||||
[/'/, 'string', '@pop'],
|
||||
],
|
||||
|
||||
clauses: [
|
||||
[/[^(,)]/, 'tag'],
|
||||
[/\)/, 'identifier', '@pop'],
|
||||
],
|
||||
|
||||
whitespace: [[/[ \t\r\n]+/, 'white']],
|
||||
},
|
||||
};
|
||||
|
||||
export const languageDefinition = {
|
||||
id: 'tagsfield',
|
||||
extensions: ['.tagsfield'],
|
||||
aliases: ['tagsfield'],
|
||||
mimetypes: [],
|
||||
def: {
|
||||
language,
|
||||
languageConfiguration,
|
||||
},
|
||||
};
|
@ -1,20 +0,0 @@
|
||||
import Prism from 'prismjs';
|
||||
|
||||
import { tokenizer } from './syntax';
|
||||
|
||||
describe('Loki syntax', () => {
|
||||
it('should highlight Loki query correctly', () => {
|
||||
expect(Prism.highlight('key=value', tokenizer, 'tempo')).toBe(
|
||||
'<span class="token key attr-name">key</span><span class="token operator">=</span><span class="token value">value</span>'
|
||||
);
|
||||
expect(Prism.highlight('root.ip=172.123.0.1', tokenizer, 'tempo')).toBe(
|
||||
'<span class="token key attr-name">root.ip</span><span class="token operator">=</span><span class="token value">172.123.0.1</span>'
|
||||
);
|
||||
expect(Prism.highlight('root.name="http get /config"', tokenizer, 'tempo')).toBe(
|
||||
'<span class="token key attr-name">root.name</span><span class="token operator">=</span><span class="token value">"http get /config"</span>'
|
||||
);
|
||||
expect(Prism.highlight('key=value key2=value2', tokenizer, 'tempo')).toBe(
|
||||
'<span class="token key attr-name">key</span><span class="token operator">=</span><span class="token value">value</span> <span class="token key attr-name">key2</span><span class="token operator">=</span><span class="token value">value2</span>'
|
||||
);
|
||||
});
|
||||
});
|
@ -1,17 +0,0 @@
|
||||
import { Grammar } from 'prismjs';
|
||||
|
||||
export const tokenizer: Grammar = {
|
||||
key: {
|
||||
pattern: /[^\s]+(?==)/,
|
||||
alias: 'attr-name',
|
||||
},
|
||||
operator: /[=]/,
|
||||
value: [
|
||||
{
|
||||
pattern: /"(.+)"/,
|
||||
},
|
||||
{
|
||||
pattern: /[^\s]+/,
|
||||
},
|
||||
],
|
||||
};
|
Loading…
Reference in New Issue
Block a user