Refactor Bruno language parser to support multiline text blocks (#2086)

* Refactor Bruno language parser to support multiline text blocks

* Refactor SingleLineEditor component to allow newlines and adjust height dynamically

* Refactor SingleLineEditor component to fix overflow issue

* Fix overflow issue in SingleLineEditor component

* Refactor SingleLineEditor and MultiLineEditor components to fix overflow issues and allow newlines

* Fix overflow and scrolling issues in MultiLineEditor component

---------

Co-authored-by: Sanjai Kumar <sk@sk.local>
This commit is contained in:
Sanjai Kumar 2024-04-17 14:19:44 +05:30 committed by GitHub
parent bc70bba0b6
commit a0860febee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 289 additions and 28 deletions

View File

@ -0,0 +1,57 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
width: 100%;
height: fit-content;
max-height: 200px;
overflow: auto;
.CodeMirror {
background: transparent;
height: fit-content;
font-size: 14px;
line-height: 30px;
overflow: hidden;
.CodeMirror-scroll {
overflow: hidden !important;
${'' /* padding-bottom: 50px !important; */}
position: relative;
display: contents;
margin: 0px;
padding: 0px;
}
.CodeMirror-vscrollbar,
.CodeMirror-hscrollbar,
.CodeMirror-scrollbar-filler {
display: none;
}
.CodeMirror-lines {
padding: 0;
}
.CodeMirror-cursor {
height: 20px !important;
margin-top: 5px !important;
border-left: 1px solid ${(props) => props.theme.text} !important;
}
pre {
font-family: Inter, sans-serif !important;
font-weight: 400;
}
.CodeMirror-line {
color: ${(props) => props.theme.text};
padding: 0;
}
.CodeMirror-selected {
background-color: rgba(212, 125, 59, 0.3);
}
}
`;
export default StyledWrapper;

View File

@ -0,0 +1,174 @@
import React, { Component } from 'react';
import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import StyledWrapper from './StyledWrapper';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
CodeMirror.registerHelper('hint', 'anyword', (editor, options) => {
const word = /[\w$-]+/;
const wordlist = (options && options.autocomplete) || [];
let cur = editor.getCursor(),
curLine = editor.getLine(cur.line);
let end = cur.ch,
start = end;
while (start && word.test(curLine.charAt(start - 1))) --start;
let curWord = start != end && curLine.slice(start, end);
// Check if curWord is a valid string before proceeding
if (typeof curWord !== 'string' || curWord.length < 3) {
return null; // Abort the hint
}
const list = (options && options.list) || [];
const re = new RegExp(word.source, 'g');
for (let dir = -1; dir <= 1; dir += 2) {
let line = cur.line,
endLine = Math.min(Math.max(line + dir * 500, editor.firstLine()), editor.lastLine()) + dir;
for (; line != endLine; line += dir) {
let text = editor.getLine(line),
m;
while ((m = re.exec(text))) {
if (line == cur.line && curWord.length < 3) continue;
list.push(...wordlist.filter((el) => el.toLowerCase().startsWith(curWord.toLowerCase())));
}
}
}
return { list: [...new Set(list)], from: CodeMirror.Pos(cur.line, start), to: CodeMirror.Pos(cur.line, end) };
});
CodeMirror.commands.autocomplete = (cm, hint, options) => {
cm.showHint({ hint, ...options });
};
}
class MultiLineEditor extends Component {
constructor(props) {
super(props);
// Keep a cached version of the value, this cache will be updated when the
// editor is updated, which can later be used to protect the editor from
// unnecessary updates during the update lifecycle.
this.cachedValue = props.value || '';
this.editorRef = React.createRef();
this.variables = {};
}
componentDidMount() {
// Initialize CodeMirror as a single line editor
/** @type {import("codemirror").Editor} */
this.editor = CodeMirror(this.editorRef.current, {
lineWrapping: false,
lineNumbers: false,
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
mode: 'brunovariables',
brunoVarInfo: {
variables: getAllVariables(this.props.collection)
},
scrollbarStyle: null,
tabindex: 0,
extraKeys: {
Enter: () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Ctrl-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Alt-Enter': () => {
this.editor.setValue(this.editor.getValue() + '\n');
this.editor.setCursor({ line: this.editor.lineCount(), ch: 0 });
},
'Shift-Enter': () => {
this.editor.setValue(this.editor.getValue() + '\n');
this.editor.setCursor({ line: this.editor.lineCount(), ch: 0 });
},
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Cmd-F': () => {},
'Ctrl-F': () => {},
// Tabbing disabled to make tabindex work
Tab: false,
'Shift-Tab': false
}
});
if (this.props.autocomplete) {
this.editor.on('keyup', (cm, event) => {
if (!cm.state.completionActive /*Enables keyboard navigation in autocomplete list*/ && event.keyCode != 13) {
/*Enter - do not open autocomplete list just after item has been selected in it*/
CodeMirror.commands.autocomplete(cm, CodeMirror.hint.anyword, { autocomplete: this.props.autocomplete });
}
});
}
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
this.addOverlay();
}
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.cachedValue = this.editor.getValue();
if (this.props.onChange) {
this.props.onChange(this.cachedValue);
}
}
};
componentDidUpdate(prevProps) {
// Ensure the changes caused by this update are not interpreted as
// user-input changes which could otherwise result in an infinite
// event loop.
this.ignoreChangeEvent = true;
let variables = getAllVariables(this.props.collection);
if (!isEqual(variables, this.variables)) {
this.editor.options.brunoVarInfo.variables = variables;
this.addOverlay();
}
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
}
if (this.editorRef?.current) {
this.editorRef.current.scrollTo(0, 10000);
}
this.ignoreChangeEvent = false;
}
componentWillUnmount() {
this.editor.getWrapperElement().remove();
}
addOverlay = () => {
let variables = getAllVariables(this.props.collection);
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain');
this.editor.setOption('mode', 'brunovariables');
};
render() {
return <StyledWrapper ref={this.editorRef} className="single-line-editor"></StyledWrapper>;
}
}
export default MultiLineEditor;

View File

@ -9,7 +9,7 @@ import {
updateFormUrlEncodedParam,
deleteFormUrlEncodedParam
} from 'providers/ReduxStore/slices/collections';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
@ -92,7 +92,7 @@ const FormUrlEncodedParams = ({ item, collection }) => {
/>
</td>
<td>
<SingleLineEditor
<MultiLineEditor
value={param.value}
theme={storedTheme}
onSave={onSave}

View File

@ -9,7 +9,7 @@ import {
updateMultipartFormParam,
deleteMultipartFormParam
} from 'providers/ReduxStore/slices/collections';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import FilePickerEditor from 'components/FilePickerEditor';
@ -121,7 +121,7 @@ const MultipartFormParams = ({ item, collection }) => {
collection={collection}
/>
) : (
<SingleLineEditor
<MultiLineEditor
onSave={onSave}
theme={storedTheme}
value={param.value}
@ -137,6 +137,7 @@ const MultipartFormParams = ({ item, collection }) => {
)
}
onRun={handleRun}
allowNewlines={true}
collection={collection}
/>
)}

View File

@ -7,6 +7,7 @@ import { useTheme } from 'providers/Theme';
import { addRequestHeader, updateRequestHeader, deleteRequestHeader } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
@ -99,7 +100,7 @@ const RequestHeaders = ({ item, collection }) => {
/>
</td>
<td>
<SingleLineEditor
<MultiLineEditor
value={header.value}
theme={storedTheme}
onSave={onSave}
@ -115,6 +116,7 @@ const RequestHeaders = ({ item, collection }) => {
)
}
onRun={handleRun}
allowNewlines={true}
collection={collection}
/>
</td>

View File

@ -13,20 +13,15 @@ const StyledWrapper = styled.div`
line-height: 30px;
overflow: hidden;
.CodeMirror-vscrollbar {
display: none !important;
}
.CodeMirror-scroll {
overflow: hidden !important;
padding-bottom: 50px !important;
}
.CodeMirror-hscrollbar {
display: none !important;
}
.CodeMirror-vscrollbar,
.CodeMirror-hscrollbar,
.CodeMirror-scrollbar-filler {
display: none !important;
display: none;
}
.CodeMirror-lines {
@ -46,8 +41,7 @@ const StyledWrapper = styled.div`
.CodeMirror-line {
color: ${(props) => props.theme.text};
padding-left: 0;
padding-right: 0;
padding: 0;
}
.CodeMirror-selected {

View File

@ -35,12 +35,16 @@ const grammar = ohm.grammar(`Bru {
keychar = ~(tagend | st | nl | ":") any
valuechar = ~(nl | tagend) any
// Multiline text block surrounded by '''
multilinetextblockdelimiter = "'''"
multilinetextblock = multilinetextblockdelimiter (~multilinetextblockdelimiter any)* multilinetextblockdelimiter
// Dictionary Blocks
dictionary = st* "{" pairlist? tagend
pairlist = optionalnl* pair (~tagend stnl* pair)* (~tagend space)*
pair = st* key st* ":" st* value st*
key = keychar*
value = valuechar*
value = multilinetextblock | valuechar*
// Dictionary for Assert Block
assertdictionary = st* "{" assertpairlist? tagend
@ -186,6 +190,19 @@ const sem = grammar.createSemantics().addAttribute('ast', {
return chars.sourceString ? chars.sourceString.trim() : '';
},
value(chars) {
try {
let isMultiline = chars.sourceString?.startsWith(`'''`) && chars.sourceString?.endsWith(`'''`);
if (isMultiline) {
const multilineString = chars.sourceString?.replace(/^'''|'''$/g, '');
return multilineString
.split('\n')
.map((line) => line.slice(4))
.join('\n');
}
return chars.sourceString ? chars.sourceString.trim() : '';
} catch (err) {
console.error(err);
}
return chars.sourceString ? chars.sourceString.trim() : '';
},
assertdictionary(_1, _2, pairlist, _3) {

View File

@ -12,6 +12,23 @@ const stripLastLine = (text) => {
return text.replace(/(\r?\n)$/, '');
};
const getValueString = (value) => {
const hasNewLines = value.includes('\n');
if (!hasNewLines) {
return value;
}
// Add one level of indentation to the contents of the multistring
const indentedLines = value
.split('\n')
.map((line) => ` ${line}`)
.join('\n');
// Join the lines back together with newline characters and enclose them in triple single quotes
return `'''\n${indentedLines}\n'''`;
};
const jsonToBru = (json) => {
const { meta, http, query, headers, auth, body, script, tests, vars, assertions, docs } = json;
@ -202,24 +219,23 @@ ${indentString(body.sparql)}
}
if (body && body.formUrlEncoded && body.formUrlEncoded.length) {
bru += `body:form-urlencoded {`;
bru += `body:form-urlencoded {\n`;
if (enabled(body.formUrlEncoded).length) {
bru += `\n${indentString(
enabled(body.formUrlEncoded)
.map((item) => `${item.name}: ${item.value}`)
.join('\n')
)}`;
const enabledValues = enabled(body.formUrlEncoded)
.map((item) => `${item.name}: ${getValueString(item.value)}`)
.join('\n');
bru += `${indentString(enabledValues)}\n`;
}
if (disabled(body.formUrlEncoded).length) {
bru += `\n${indentString(
disabled(body.formUrlEncoded)
.map((item) => `~${item.name}: ${item.value}`)
.join('\n')
)}`;
const disabledValues = disabled(body.formUrlEncoded)
.map((item) => `~${item.name}: ${getValueString(item.value)}`)
.join('\n');
bru += `${indentString(disabledValues)}\n`;
}
bru += '\n}\n\n';
bru += '}\n\n';
}
if (body && body.multipartForm && body.multipartForm.length) {