diff --git a/packages/kbn-monaco/src/xjson/grammar.test.ts b/packages/kbn-monaco/src/xjson/grammar.test.ts new file mode 100644 index 0000000000000..29d338cd71b0c --- /dev/null +++ b/packages/kbn-monaco/src/xjson/grammar.test.ts @@ -0,0 +1,189 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createParser } from './grammar'; + +describe('createParser', () => { + let parser: ReturnType; + + beforeEach(() => { + parser = createParser(); + }); + + test('should create a xjson grammar parser', () => { + expect(createParser()).toBeInstanceOf(Function); + }); + + test('should return no annotations in case of valid json', () => { + expect( + parser(` + {"menu": { + "id": "file", + "value": "File", + "quotes": "'\\"", + "popup": { + "actions": [ + "new", + "open", + "close" + ], + "menuitem": [ + {"value": "New"}, + {"value": "Open"}, + {"value": "Close"} + ] + } + }} + `) + ).toMatchInlineSnapshot(` + Object { + "annotations": Array [], + } + `); + }); + + test('should support triple quotes', () => { + expect( + parser(` + {"menu": { + "id": """ + file + """, + "value": "File" + }} + `) + ).toMatchInlineSnapshot(` + Object { + "annotations": Array [], + } + `); + }); + + test('triple quotes should be correctly closed', () => { + expect( + parser(` + {"menu": { + "id": """" + file + "", + "value": "File" + }} + `) + ).toMatchInlineSnapshot(` + Object { + "annotations": Array [ + Object { + "at": 36, + "text": "Expected ',' instead of '\\"'", + "type": "error", + }, + ], + } + `); + }); + + test('an escaped quote can be appended to the end of triple quotes', () => { + expect( + parser(` + {"menu": { + "id": """ + file + \\"""", + "value": "File" + }} + `) + ).toMatchInlineSnapshot(` + Object { + "annotations": Array [], + } + `); + }); + + test('text values should be wrapper into quotes', () => { + expect( + parser(` + {"menu": { + "id": id, + "value": "File" + }} + `) + ).toMatchInlineSnapshot(` + Object { + "annotations": Array [ + Object { + "at": 36, + "text": "Unexpected 'i'", + "type": "error", + }, + ], + } + `); + }); + + test('check for close quotes', () => { + expect( + parser(` + {"menu": { + "id": "id, + "value": "File" + }} + `) + ).toMatchInlineSnapshot(` + Object { + "annotations": Array [ + Object { + "at": 52, + "text": "Expected ',' instead of 'v'", + "type": "error", + }, + ], + } + `); + }); + test('no duplicate keys', () => { + expect( + parser(` + {"menu": { + "id": "id", + "id": "File" + }} + `) + ).toMatchInlineSnapshot(` + Object { + "annotations": Array [ + Object { + "at": 53, + "text": "Duplicate key \\"id\\"", + "type": "warning", + }, + ], + } + `); + }); + + test('all curly quotes should be closed', () => { + expect( + parser(` + {"menu": { + "id": "id", + "name": "File" + } + `) + ).toMatchInlineSnapshot(` + Object { + "annotations": Array [ + Object { + "at": 82, + "text": "Expected ',' instead of ''", + "type": "error", + }, + ], + } + `); + }); +}); diff --git a/packages/kbn-monaco/src/xjson/grammar.ts b/packages/kbn-monaco/src/xjson/grammar.ts index 32c958e66d594..5d26e92f005ba 100644 --- a/packages/kbn-monaco/src/xjson/grammar.ts +++ b/packages/kbn-monaco/src/xjson/grammar.ts @@ -57,10 +57,6 @@ export const createParser = () => { text: m, }); }, - reset = function (newAt: number) { - ch = text.charAt(newAt); - at = newAt + 1; - }, next = function (c?: string) { return ( c && c !== ch && error("Expected '" + c + "' instead of '" + ch + "'"), @@ -69,15 +65,6 @@ export const createParser = () => { ch ); }, - nextUpTo = function (upTo: any, errorMessage: string) { - let currentAt = at, - i = text.indexOf(upTo, currentAt); - if (i < 0) { - error(errorMessage || "Expected '" + upTo + "'"); - } - reset(i + upTo.length); - return text.substring(currentAt, i); - }, peek = function (c: string) { return text.substr(at, c.length) === c; // nocommit - double check }, @@ -96,37 +83,50 @@ export const createParser = () => { (string += ch), next(); return (number = +string), isNaN(number) ? (error('Bad number'), void 0) : number; }, + stringLiteral = function () { + let quotes = '"""'; + let end = text.indexOf('\\"' + quotes, at + quotes.length); + + if (end >= 0) { + quotes = '\\"' + quotes; + } else { + end = text.indexOf(quotes, at + quotes.length); + } + + if (end >= 0) { + for (let l = end - at + quotes.length; l > 0; l--) { + next(); + } + } + + return next(); + }, string = function () { let hex: any, i: any, uffff: any, string = ''; + if ('"' === ch) { - if (peek('""')) { - // literal - next('"'); - next('"'); - return nextUpTo('"""', 'failed to find closing \'"""\''); - } else { - for (; next(); ) { - if ('"' === ch) return next(), string; - if ('\\' === ch) - if ((next(), 'u' === ch)) { - for ( - uffff = 0, i = 0; - 4 > i && ((hex = parseInt(next(), 16)), isFinite(hex)); - i += 1 - ) - uffff = 16 * uffff + hex; - string += String.fromCharCode(uffff); - } else { - if ('string' != typeof escapee[ch]) break; - string += escapee[ch]; - } - else string += ch; - } + for (; next(); ) { + if ('"' === ch) return next(), string; + if ('\\' === ch) + if ((next(), 'u' === ch)) { + for ( + uffff = 0, i = 0; + 4 > i && ((hex = parseInt(next(), 16)), isFinite(hex)); + i += 1 + ) + uffff = 16 * uffff + hex; + string += String.fromCharCode(uffff); + } else { + if ('string' != typeof escapee[ch]) break; + string += escapee[ch]; + } + else string += ch; } } + error('Bad string'); }, white = function () { @@ -165,9 +165,9 @@ export const createParser = () => { ((key = string()), white(), next(':'), - Object.hasOwnProperty.call(object, key) && + Object.hasOwnProperty.call(object, key!) && warning('Duplicate key "' + key + '"', latchKeyStart), - (object[key] = value()), + (object[key!] = value()), white(), '}' === ch) ) @@ -179,6 +179,9 @@ export const createParser = () => { }; return ( (value = function () { + if (peek('"""')) { + return stringLiteral(); + } switch ((white(), ch)) { case '{': return object(); diff --git a/packages/kbn-monaco/src/xjson/lexer_rules/xjson.ts b/packages/kbn-monaco/src/xjson/lexer_rules/xjson.ts index 2c8186ac7fa4f..f2ab22f8c97df 100644 --- a/packages/kbn-monaco/src/xjson/lexer_rules/xjson.ts +++ b/packages/kbn-monaco/src/xjson/lexer_rules/xjson.ts @@ -103,6 +103,7 @@ export const lexerRules: monaco.languages.IMonarchLanguage = { string_literal: [ [/"""/, { token: 'punctuation.end_triple_quote', next: '@pop' }], + [/\\""""/, { token: 'punctuation.end_triple_quote', next: '@pop' }], [/./, { token: 'multi_string' }], ], }, diff --git a/src/plugins/data/common/search/aggs/param_types/json.test.ts b/src/plugins/data/common/search/aggs/param_types/json.test.ts index 1b3af5b92c26b..8e71cf4657e1f 100644 --- a/src/plugins/data/common/search/aggs/param_types/json.test.ts +++ b/src/plugins/data/common/search/aggs/param_types/json.test.ts @@ -67,10 +67,34 @@ describe('JSON', function () { aggParam.write(aggConfig, output); expect(aggConfig.params).toHaveProperty(paramName); - expect(output.params).toEqual({ - existing: 'true', - new_param: 'should exist in output', - }); + expect(output.params).toMatchInlineSnapshot(` + Object { + "existing": "true", + "new_param": "should exist in output", + } + `); + }); + + it('should append param when valid JSON with triple quotes', () => { + const aggParam = initAggParam(); + const jsonData = `{ + "a": """ + multiline string - line 1 + """ + }`; + + aggConfig.params[paramName] = jsonData; + + aggParam.write(aggConfig, output); + expect(aggConfig.params).toHaveProperty(paramName); + + expect(output.params).toMatchInlineSnapshot(` + Object { + "a": " + multiline string - line 1 + ", + } + `); }); it('should not overwrite existing params', () => { diff --git a/src/plugins/data/common/search/aggs/param_types/json.ts b/src/plugins/data/common/search/aggs/param_types/json.ts index 1678b6586ce80..f499286140af1 100644 --- a/src/plugins/data/common/search/aggs/param_types/json.ts +++ b/src/plugins/data/common/search/aggs/param_types/json.ts @@ -11,6 +11,17 @@ import _ from 'lodash'; import { IAggConfig } from '../agg_config'; import { BaseParamType } from './base'; +function collapseLiteralStrings(xjson: string) { + const tripleQuotes = '"""'; + const splitData = xjson.split(tripleQuotes); + + for (let idx = 1; idx < splitData.length - 1; idx += 2) { + splitData[idx] = JSON.stringify(splitData[idx]); + } + + return splitData.join(''); +} + export class JsonParamType extends BaseParamType { constructor(config: Record) { super(config); @@ -26,9 +37,8 @@ export class JsonParamType extends BaseParamType { return; } - // handle invalid Json input try { - paramJson = JSON.parse(param); + paramJson = JSON.parse(collapseLiteralStrings(param)); } catch (err) { return; } diff --git a/src/plugins/vis_default_editor/kibana.json b/src/plugins/vis_default_editor/kibana.json index efed1eab1e494..253edc74f87b4 100644 --- a/src/plugins/vis_default_editor/kibana.json +++ b/src/plugins/vis_default_editor/kibana.json @@ -3,7 +3,7 @@ "version": "kibana", "ui": true, "optionalPlugins": ["visualize"], - "requiredBundles": ["kibanaUtils", "kibanaReact", "data", "fieldFormats", "discover"], + "requiredBundles": ["kibanaUtils", "kibanaReact", "data", "fieldFormats", "discover", "esUiShared"], "owner": { "name": "Vis Editors", "githubTeam": "kibana-vis-editors" diff --git a/src/plugins/vis_default_editor/public/components/controls/raw_json.tsx b/src/plugins/vis_default_editor/public/components/controls/raw_json.tsx index af6096be87f59..6e5ae78e54dc1 100644 --- a/src/plugins/vis_default_editor/public/components/controls/raw_json.tsx +++ b/src/plugins/vis_default_editor/public/components/controls/raw_json.tsx @@ -12,6 +12,7 @@ import { EuiFormRow, EuiIconTip, EuiScreenReaderOnly } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { XJsonLang } from '@kbn/monaco'; import { CodeEditor } from '../../../../kibana_react/public'; +import { XJson } from '../../../../es_ui_shared/public'; import { AggParamEditorProps } from '../agg_param_props'; @@ -58,7 +59,7 @@ function RawJsonParamEditor({ let isJsonValid = true; try { if (newValue) { - JSON.parse(newValue); + JSON.parse(XJson.collapseLiteralStrings(newValue)); } } catch (e) { isJsonValid = false;