diff --git a/docs/sources/datasources/loki.md b/docs/sources/datasources/loki.md index 1795ae7e92d..82709de20ab 100644 --- a/docs/sources/datasources/loki.md +++ b/docs/sources/datasources/loki.md @@ -106,11 +106,11 @@ With Loki log browser you can easily navigate through your list of labels and va In addition to `Run query` button and mode switcher, in builder mode additional elements are available: -| Name | Description | -| -------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| Query patterns | A list of useful operation patterns that can be used to quickly add multiple operations to your query to achieve a specific goal. | -| Explain | Toggle to show a step by step explanation of all query parts and the operations. | -| Raw query | Toggle to show raw query generated by the builder that will be sent to Loki instance. | +| Name | Description | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Kick start your query | A list of useful operation patterns that can be used to quickly add multiple operations to your query to achieve a specific goal. | +| Explain | Toggle to show a step by step explanation of all query parts and the operations. | +| Raw query | Toggle to show raw query generated by the builder that will be sent to Loki instance. | #### Labels selector @@ -293,6 +293,6 @@ datasources: type: jaeger url: http://jaeger-tracing-query:16686/ access: proxy - # UID should match the datasourceUid in dervidedFields. + # UID should match the datasourceUid in derivedFields. uid: my_jaeger_uid ``` diff --git a/e2e/various-suite/loki-query-builder.spec.ts b/e2e/various-suite/loki-query-builder.spec.ts index 13419aa0d43..32ee27e0d5e 100644 --- a/e2e/various-suite/loki-query-builder.spec.ts +++ b/e2e/various-suite/loki-query-builder.spec.ts @@ -45,7 +45,9 @@ describe('Loki query builder', () => { e2e().contains(dataSourceName).scrollIntoView().should('be.visible').click(); // Start in builder mode, click and choose query pattern - e2e.components.QueryBuilder.queryPatterns().click().type('Log query with parsing{enter}'); + e2e.components.QueryBuilder.queryPatterns().click(); + e2e().contains('Log query starters').click(); + e2e().contains('Use this query').click(); e2e().contains('No pipeline errors').should('be.visible'); e2e().contains('Logfmt').should('be.visible'); e2e().contains('{} | logfmt | __error__=``').should('be.visible'); diff --git a/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts b/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts index 0b68e34a4dc..ed6e99e94af 100644 --- a/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts +++ b/public/app/plugins/datasource/loki/querybuilder/LokiQueryModeller.ts @@ -2,7 +2,7 @@ import { LokiAndPromQueryModellerBase } from '../../prometheus/querybuilder/shar import { QueryBuilderLabelFilter } from '../../prometheus/querybuilder/shared/types'; import { getOperationDefinitions } from './operations'; -import { LokiOperationId, LokiQueryPattern, LokiVisualQueryOperationCategory } from './types'; +import { LokiOperationId, LokiQueryPattern, LokiQueryPatternType, LokiVisualQueryOperationCategory } from './types'; export class LokiQueryModeller extends LokiAndPromQueryModellerBase { constructor() { @@ -29,7 +29,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase { getQueryPatterns(): LokiQueryPattern[] { return [ { - name: 'Log query with parsing', + name: 'Parse log lines with logfmt parser', + type: LokiQueryPatternType.Log, // {} | logfmt | __error__=`` operations: [ { id: LokiOperationId.Logfmt, params: [] }, @@ -37,7 +38,17 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase { ], }, { - name: 'Log query with filtering and parsing', + name: 'Parse log lines with JSON parser', + type: LokiQueryPatternType.Log, + // {} | json | __error__=`` + operations: [ + { id: LokiOperationId.Json, params: [] }, + { id: LokiOperationId.LabelFilterNoErrors, params: [] }, + ], + }, + { + name: 'Filter log line and parse with logfmt parser', + type: LokiQueryPatternType.Log, // {} |= `` | logfmt | __error__=`` operations: [ { id: LokiOperationId.LineContains, params: [''] }, @@ -46,7 +57,18 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase { ], }, { - name: 'Log query with parsing and label filter', + name: 'Filter log lines and parse with json parser', + type: LokiQueryPatternType.Log, + // {} |= `` | json | __error__=`` + operations: [ + { id: LokiOperationId.LineContains, params: [''] }, + { id: LokiOperationId.Json, params: [] }, + { id: LokiOperationId.LabelFilterNoErrors, params: [] }, + ], + }, + { + name: 'Parse log line with logfmt parser and use label filter', + type: LokiQueryPatternType.Log, // {} |= `` | logfmt | __error__=`` | label=`value` operations: [ { id: LokiOperationId.LineContains, params: [''] }, @@ -56,7 +78,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase { ], }, { - name: 'Log query with parsing of nested json', + name: 'Parse log lines with nested json', + type: LokiQueryPatternType.Log, // {} |= `` | json | line_format `{{ .message}}` | json operations: [ { id: LokiOperationId.LineContains, params: [''] }, @@ -68,7 +91,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase { ], }, { - name: 'Log query with reformatted log line', + name: 'Reformat log lines', + type: LokiQueryPatternType.Log, // {} |= `` | logfmt | line_format `{{.message}}` operations: [ { id: LokiOperationId.LineContains, params: [''] }, @@ -78,7 +102,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase { ], }, { - name: 'Log query with mapped log level', + name: 'Rename lvl label to level', + type: LokiQueryPatternType.Log, // {} |= `` | logfmt | label_format level=lvl operations: [ { id: LokiOperationId.LineContains, params: [''] }, @@ -88,7 +113,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase { ], }, { - name: 'Metrics query on value inside log line', + name: 'Query on value inside a log line', + type: LokiQueryPatternType.Metric, // sum(sum_over_time({ | logfmt | __error__=`` | unwrap | __error__=`` [$__interval])) operations: [ { id: LokiOperationId.LineContains, params: [''] }, @@ -101,7 +127,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase { ], }, { - name: 'Metrics query for total requests per label of streams', + name: 'Total requests per label of streams', + type: LokiQueryPatternType.Metric, // sum by() (count_over_time({}[$__interval) operations: [ { id: LokiOperationId.LineContains, params: [''] }, @@ -110,7 +137,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase { ], }, { - name: 'Metrics query for total requests per parsed label or label of streams', + name: 'Total requests per parsed label or label of streams', + type: LokiQueryPatternType.Metric, // sum by() (count_over_time({}| logfmt | __error__=`` [$__interval)) operations: [ { id: LokiOperationId.LineContains, params: [''] }, @@ -121,7 +149,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase { ], }, { - name: 'Metrics query for bytes used by log stream', + name: 'Bytes used by a log stream', + type: LokiQueryPatternType.Metric, // bytes_over_time({}[$__interval]) operations: [ { id: LokiOperationId.LineContains, params: [''] }, @@ -129,7 +158,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase { ], }, { - name: 'Metrics query for count of log lines per stream', + name: 'Count of log lines per stream', + type: LokiQueryPatternType.Metric, // count_over_time({}[$__interval]) operations: [ { id: LokiOperationId.LineContains, params: [''] }, @@ -137,7 +167,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase { ], }, { - name: 'Metrics query for top n results by label or parsed label', + name: 'Top N results by label or parsed label', + type: LokiQueryPatternType.Metric, // topk(10, sum by () (count_over_time({} | logfmt | __error__=`` [$__interval]))) operations: [ { id: LokiOperationId.Logfmt, params: [] }, @@ -148,7 +179,8 @@ export class LokiQueryModeller extends LokiAndPromQueryModellerBase { ], }, { - name: 'Metrics query for extracted quantile', + name: 'Extracted quantile', + type: LokiQueryPatternType.Metric, // quantile_over_time(0.5,{} | logfmt | unwrap latency[$__interval]) by () operations: [ { id: LokiOperationId.Logfmt, params: [] }, diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx index a6015ad8df4..8d0f6b9b789 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryEditorSelector.tsx @@ -1,9 +1,9 @@ import React, { SyntheticEvent, useCallback, useEffect, useState } from 'react'; -import { CoreApp, LoadingState, SelectableValue } from '@grafana/data'; +import { CoreApp, LoadingState } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { reportInteraction } from '@grafana/runtime'; -import { Button, ConfirmModal, EditorHeader, EditorRows, FlexItem, InlineSelect, Space } from '@grafana/ui'; +import { Button, ConfirmModal, EditorHeader, EditorRows, FlexItem, Space } from '@grafana/ui'; import { QueryEditorModeToggle } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryEditorModeToggle'; import { QueryHeaderSwitch } from 'app/plugins/datasource/prometheus/querybuilder/shared/QueryHeaderSwitch'; import { QueryEditorMode } from 'app/plugins/datasource/prometheus/querybuilder/shared/types'; @@ -15,18 +15,18 @@ import { } from '../../../prometheus/querybuilder/shared/hooks/useFlag'; import { LokiQueryEditorProps } from '../../components/types'; import { LokiQuery } from '../../types'; -import { lokiQueryModeller } from '../LokiQueryModeller'; import { buildVisualQueryFromString } from '../parsing'; import { changeEditorMode, getQueryWithDefaults } from '../state'; -import { LokiQueryPattern } from '../types'; import { LokiQueryBuilderContainer } from './LokiQueryBuilderContainer'; import { LokiQueryBuilderOptions } from './LokiQueryBuilderOptions'; import { LokiQueryCodeEditor } from './LokiQueryCodeEditor'; +import { QueryPatternsModal } from './QueryPatternsModal'; export const LokiQueryEditorSelector = React.memo((props) => { - const { onChange, onRunQuery, data, app } = props; + const { onChange, onRunQuery, onAddQuery, data, app, queries } = props; const [parseModalOpen, setParseModalOpen] = useState(false); + const [queryPatternsModalOpen, setQueryPatternsModalOpen] = useState(false); const [dataIsStale, setDataIsStale] = useState(false); const { flag: explain, setFlag: setExplain } = useFlag(lokiQueryEditorExplainKey); const { flag: rawQuery, setFlag: setRawQuery } = useFlag(lokiQueryEditorRawQueryKey, true); @@ -88,42 +88,35 @@ export const LokiQueryEditorSelector = React.memo((props) }} onDismiss={() => setParseModalOpen(false)} /> + setQueryPatternsModalOpen(false)} + query={query} + queries={queries} + app={app} + onChange={onChange} + onAddQuery={onAddQuery} + /> - { + {editorMode === QueryEditorMode.Builder && ( <> diff --git a/public/app/plugins/datasource/loki/querybuilder/components/QueryPattern.tsx b/public/app/plugins/datasource/loki/querybuilder/components/QueryPattern.tsx new file mode 100644 index 00000000000..a1b00f42842 --- /dev/null +++ b/public/app/plugins/datasource/loki/querybuilder/components/QueryPattern.tsx @@ -0,0 +1,109 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, Card, useStyles2 } from '@grafana/ui'; +import { RawQuery } from 'app/plugins/datasource/prometheus/querybuilder/shared/RawQuery'; + +import logqlGrammar from '../../syntax'; +import { lokiQueryModeller } from '../LokiQueryModeller'; +import { LokiQueryPattern } from '../types'; + +type Props = { + pattern: LokiQueryPattern; + hasNewQueryOption: boolean; + hasPreviousQuery: boolean; + selectedPatternName: string | null; + setSelectedPatternName: (name: string | null) => void; + onPatternSelect: (pattern: LokiQueryPattern, selectAsNewQuery?: boolean) => void; +}; + +export const QueryPattern = (props: Props) => { + const { pattern, onPatternSelect, hasNewQueryOption, hasPreviousQuery, selectedPatternName, setSelectedPatternName } = + props; + + const styles = useStyles2(getStyles); + const lang = { grammar: logqlGrammar, name: 'logql' }; + + return ( + + {pattern.name} +
+ +
+ + {selectedPatternName !== pattern.name ? ( + + ) : ( + <> +
+ {`If you would like to use this query, ${ + hasNewQueryOption + ? 'you can either replace your current query or create a new query' + : 'your current query will be replaced' + }.`} +
+ + + {hasNewQueryOption && ( + + )} + + )} +
+
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + card: css` + width: 49.5%; + display: flex; + flex-direction: column; + `, + rawQueryContainer: css` + flex-grow: 1; + `, + rawQuery: css` + background-color: ${theme.colors.background.primary}; + padding: ${theme.spacing(1)}; + margin-top: ${theme.spacing(1)}; + `, + spacing: css` + margin-bottom: ${theme.spacing(1)}; + `, + }; +}; diff --git a/public/app/plugins/datasource/loki/querybuilder/components/QueryPatternsModal.test.tsx b/public/app/plugins/datasource/loki/querybuilder/components/QueryPatternsModal.test.tsx new file mode 100644 index 00000000000..5aef594b667 --- /dev/null +++ b/public/app/plugins/datasource/loki/querybuilder/components/QueryPatternsModal.test.tsx @@ -0,0 +1,124 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { lokiQueryModeller } from '../LokiQueryModeller'; +import { LokiQueryPatternType } from '../types'; + +import { QueryPatternsModal } from './QueryPatternsModal'; + +// don't care about interaction tracking in our unit tests +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), +})); + +const defaultProps = { + isOpen: true, + onClose: jest.fn(), + onChange: jest.fn(), + onAddQuery: jest.fn(), + query: { + refId: 'A', + expr: '{label1="foo", label2="bar"} |= "baz" |~ "qux"', + }, + queries: [ + { + refId: 'A', + expr: '{label1="foo", label2="bar"}', + }, + ], +}; + +const queryPatterns = { + logQueryPatterns: lokiQueryModeller.getQueryPatterns().filter((pattern) => pattern.type === LokiQueryPatternType.Log), + metricQueryPatterns: lokiQueryModeller + .getQueryPatterns() + .filter((pattern) => pattern.type === LokiQueryPatternType.Metric), +}; + +describe('QueryPatternsModal', () => { + it('renders the modal', () => { + render(); + expect(screen.getByText('Kick start your query')).toBeInTheDocument(); + }); + it('renders collapsible elements with all query pattern types', () => { + render(); + Object.values(LokiQueryPatternType).forEach((pattern) => { + expect(screen.getByText(new RegExp(`${pattern} query starters`, 'i'))).toBeInTheDocument(); + }); + }); + it('can open and close query patterns section', async () => { + render(); + await userEvent.click(screen.getByText('Log query starters')); + expect(screen.getByText(queryPatterns.logQueryPatterns[0].name)).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Log query starters')); + expect(screen.queryByText(queryPatterns.logQueryPatterns[0].name)).not.toBeInTheDocument(); + }); + + it('can open and close multiple query patterns section', async () => { + render(); + await userEvent.click(screen.getByText('Log query starters')); + expect(screen.getByText(queryPatterns.logQueryPatterns[0].name)).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Metric query starters')); + expect(screen.getByText(queryPatterns.metricQueryPatterns[0].name)).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Log query starters')); + expect(screen.queryByText(queryPatterns.logQueryPatterns[0].name)).not.toBeInTheDocument(); + + // Metric patterns should still be open + expect(screen.getByText(queryPatterns.metricQueryPatterns[0].name)).toBeInTheDocument(); + }); + + it('uses pattern if there is no existing query', async () => { + render(); + await userEvent.click(screen.getByText('Log query starters')); + expect(screen.getByText(queryPatterns.logQueryPatterns[0].name)).toBeInTheDocument(); + const firstUseQueryButton = screen.getAllByRole('button', { name: 'Use this query' })[0]; + await userEvent.click(firstUseQueryButton); + await waitFor(() => { + expect(defaultProps.onChange).toHaveBeenCalledWith({ + expr: '{job="grafana"} | logfmt | __error__=``', + refId: 'A', + }); + }); + }); + + it('gives warning when selecting pattern if there is already existing query', async () => { + render(); + await userEvent.click(screen.getByText('Log query starters')); + expect(screen.getByText(queryPatterns.logQueryPatterns[0].name)).toBeInTheDocument(); + const firstUseQueryButton = screen.getAllByRole('button', { name: 'Use this query' })[0]; + await userEvent.click(firstUseQueryButton); + expect(screen.getByText(/replace your current query or create a new query/)).toBeInTheDocument(); + }); + + it('can use create new query when selecting pattern if there is already existing query', async () => { + render(); + await userEvent.click(screen.getByText('Log query starters')); + expect(screen.getByText(queryPatterns.logQueryPatterns[0].name)).toBeInTheDocument(); + const firstUseQueryButton = screen.getAllByRole('button', { name: 'Use this query' })[0]; + await userEvent.click(firstUseQueryButton); + const createNewQueryButton = screen.getByRole('button', { name: 'Create new query' }); + expect(createNewQueryButton).toBeInTheDocument(); + await userEvent.click(createNewQueryButton); + await waitFor(() => { + expect(defaultProps.onAddQuery).toHaveBeenCalledWith({ + expr: '{} | logfmt | __error__=``', + refId: 'B', + }); + }); + }); + + it('does not show create new query option if onAddQuery function is not provided ', async () => { + render(); + await userEvent.click(screen.getByText('Log query starters')); + expect(screen.getByText(queryPatterns.logQueryPatterns[0].name)).toBeInTheDocument(); + const useQueryButton = screen.getAllByRole('button', { name: 'Use this query' })[0]; + await userEvent.click(useQueryButton); + expect(screen.queryByRole('button', { name: 'Create new query' })).not.toBeInTheDocument(); + expect(screen.getByText(/your current query will be replaced/)).toBeInTheDocument(); + }); +}); diff --git a/public/app/plugins/datasource/loki/querybuilder/components/QueryPatternsModal.tsx b/public/app/plugins/datasource/loki/querybuilder/components/QueryPatternsModal.tsx new file mode 100644 index 00000000000..265626fe454 --- /dev/null +++ b/public/app/plugins/datasource/loki/querybuilder/components/QueryPatternsModal.tsx @@ -0,0 +1,125 @@ +import { css } from '@emotion/css'; +import { capitalize } from 'lodash'; +import React, { useMemo, useState } from 'react'; + +import { CoreApp, DataQuery, GrafanaTheme2 } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { Button, Collapse, Modal, useStyles2 } from '@grafana/ui'; +import { getNextRefIdChar } from 'app/core/utils/query'; + +import { LokiQuery } from '../../types'; +import { lokiQueryModeller } from '../LokiQueryModeller'; +import { buildVisualQueryFromString } from '../parsing'; +import { LokiQueryPattern, LokiQueryPatternType } from '../types'; + +import { QueryPattern } from './QueryPattern'; + +type Props = { + isOpen: boolean; + query: LokiQuery; + queries: DataQuery[] | undefined; + app?: CoreApp; + onClose: () => void; + onChange: (query: LokiQuery) => void; + onAddQuery?: (query: LokiQuery) => void; +}; + +export const QueryPatternsModal = (props: Props) => { + const { isOpen, onClose, onChange, onAddQuery, query, queries, app } = props; + const [openTabs, setOpenTabs] = useState([]); + const [selectedPatternName, setSelectedPatternName] = useState(null); + + const styles = useStyles2(getStyles); + const hasNewQueryOption = !!onAddQuery; + const hasPreviousQuery = useMemo( + () => buildVisualQueryFromString(query.expr).query.operations.length > 0, + [query.expr] + ); + + const onPatternSelect = (pattern: LokiQueryPattern, selectAsNewQuery = false) => { + const visualQuery = buildVisualQueryFromString(selectAsNewQuery ? '' : query.expr); + reportInteraction('grafana_loki_query_patterns_selected', { + version: 'v2', + app: app ?? '', + editorMode: query.editorMode, + selectedPattern: pattern.name, + preSelectedOperationsCount: visualQuery.query.operations.length, + preSelectedLabelsCount: visualQuery.query.labels.length, + createNewQuery: hasNewQueryOption && selectAsNewQuery, + }); + + visualQuery.query.operations = pattern.operations; + if (hasNewQueryOption && selectAsNewQuery) { + onAddQuery({ + ...query, + refId: getNextRefIdChar(queries ?? [query]), + expr: lokiQueryModeller.renderQuery(visualQuery.query), + }); + } else { + onChange({ + ...query, + expr: lokiQueryModeller.renderQuery(visualQuery.query), + }); + } + setSelectedPatternName(null); + onClose(); + }; + + return ( + +
+ Kick start your query by selecting one of these queries. You can then continue to complete your query. +
+ {Object.values(LokiQueryPatternType).map((patternType) => { + return ( + + setOpenTabs((tabs) => + // close tab if it's already open, otherwise open it + tabs.includes(patternType) ? tabs.filter((t) => t !== patternType) : [...tabs, patternType] + ) + } + > +
+ {lokiQueryModeller + .getQueryPatterns() + .filter((pattern) => pattern.type === patternType) + .map((pattern) => ( + + ))} +
+
+ ); + })} + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + cardsContainer: css` + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + `, + spacing: css` + margin-bottom: ${theme.spacing(1)}; + `, + }; +}; diff --git a/public/app/plugins/datasource/loki/querybuilder/types.ts b/public/app/plugins/datasource/loki/querybuilder/types.ts index 5c2c39037da..3713eca688f 100644 --- a/public/app/plugins/datasource/loki/querybuilder/types.ts +++ b/public/app/plugins/datasource/loki/querybuilder/types.ts @@ -11,10 +11,15 @@ export interface LokiVisualQuery { } export type LokiVisualQueryBinary = VisualQueryBinary; +export enum LokiQueryPatternType { + Log = 'log', + Metric = 'metric', +} export interface LokiQueryPattern { name: string; operations: QueryBuilderOperation[]; + type: LokiQueryPatternType; } export enum LokiVisualQueryOperationCategory { diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/RawQuery.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/RawQuery.tsx index 57151312947..4d1f727e9f3 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/RawQuery.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/RawQuery.tsx @@ -11,15 +11,16 @@ export interface Props { grammar: Grammar; name: string; }; + className?: string; } -export function RawQuery({ query, lang }: Props) { +export function RawQuery({ query, lang, className }: Props) { const theme = useTheme2(); const styles = getStyles(theme); const highlighted = Prism.highlight(query, lang.grammar, lang.name); return (