Skip to content

Commit

Permalink
Adds formatting to editor by keybind and button (#335)
Browse files Browse the repository at this point in the history
This commit allows the Query Editor to interact with the formatting tool. The editor calls the formatting either by a button or by key bind.
  • Loading branch information
simonthuresson authored Jan 30, 2025
1 parent d7e0e1a commit b3f1b78
Show file tree
Hide file tree
Showing 6 changed files with 160 additions and 7 deletions.
54 changes: 52 additions & 2 deletions packages/language-support/src/formatting/formatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from '../generated-parser/CypherCmdParser';
import CypherCmdParserVisitor from '../generated-parser/CypherCmdParserVisitor';
import {
findTargetToken,
getParseTreeAndTokens,
handleMergeClause,
isComment,
Expand All @@ -41,6 +42,8 @@ export class TreePrintVisitor extends CypherCmdParserVisitor<void> {
buffer: string[] = [];
indentation = 0;
indentationSpaces = 2;
targetToken?: number;
cursorPos?: number;

constructor(private tokenStream: CommonTokenStream) {
super();
Expand Down Expand Up @@ -191,14 +194,19 @@ export class TreePrintVisitor extends CypherCmdParserVisitor<void> {
this.addSpace();
}
}

if (node.symbol.type === CypherCmdLexer.EOF) {
return;
}
if (node.symbol.tokenIndex === this.targetToken) {
this.cursorPos = this.buffer.join('').length;
}
if (wantsToBeUpperCase(node)) {
this.buffer.push(node.getText().toUpperCase());
} else {
this.buffer.push(node.getText());
}

if (wantsSpaceAfter(node)) {
this.addSpace();
}
Expand All @@ -223,6 +231,9 @@ export class TreePrintVisitor extends CypherCmdParserVisitor<void> {
if (options?.upperCase) {
result = result.toUpperCase();
}
if (node.symbol.tokenIndex === this.targetToken) {
this.cursorPos = this.buffer.join('').length;
}
this.buffer.push(result);
this.addCommentsAfter(node);
};
Expand Down Expand Up @@ -372,8 +383,47 @@ export class TreePrintVisitor extends CypherCmdParserVisitor<void> {
};
}

export function formatQuery(query: string) {
interface FormattingResultWithCursor {
formattedString: string;
newCursorPos: number;
}

export function formatQuery(query: string): string;
export function formatQuery(
query: string,
cursorPosition: number,
): FormattingResultWithCursor;
export function formatQuery(
query: string,
cursorPosition?: number,
): string | FormattingResultWithCursor {
const { tree, tokens } = getParseTreeAndTokens(query);
const visitor = new TreePrintVisitor(tokens);
return visitor.format(tree);

tokens.fill();

if (cursorPosition === undefined) return visitor.format(tree);

if (cursorPosition >= query.length || cursorPosition <= 0) {
const result = visitor.format(tree);
return {
formattedString: result,
newCursorPos: cursorPosition === 0 ? 0 : result.length,
};
}

const targetToken = findTargetToken(tokens.tokens, cursorPosition);
if (!targetToken) {
return {
formattedString: visitor.format(tree),
newCursorPos: 0,
};
}
const relativePosition = cursorPosition - targetToken.start;
visitor.targetToken = targetToken.tokenIndex;

return {
formattedString: visitor.format(tree),
newCursorPos: visitor.cursorPos + relativePosition,
};
}
16 changes: 16 additions & 0 deletions packages/language-support/src/formatting/formattingHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,19 @@ export function getParseTreeAndTokens(query: string) {
const tree = parser.statementsOrCommands();
return { tree, tokens };
}

export function findTargetToken(
tokens: Token[],
cursorPosition: number,
): Token | false {
let targetToken: Token;
for (const token of tokens) {
if (token.channel === 0) {
targetToken = token;
}
if (cursorPosition >= token.start && cursorPosition <= token.stop) {
return targetToken;
}
}
return false;
}
1 change: 1 addition & 0 deletions packages/language-support/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ export type { CompletionItem, Neo4jFunction, Neo4jProcedure } from './types';
export { CypherLexer, CypherParser };
import CypherLexer from './generated-parser/CypherCmdLexer';
import CypherParser from './generated-parser/CypherCmdParser';
export { formatQuery } from './formatting/formatting'
47 changes: 47 additions & 0 deletions packages/language-support/src/tests/formatting/formatting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,50 @@ describe('various edgecases', () => {
verifyFormatting(query, expected);
});
});

describe('tests for correct cursor position', () => {
test('cursor at beginning', () => {
const query = 'RETURN -1, -2, -3';
const result = formatQuery(query, 0);
expect(result.newCursorPos).toEqual(0);
});
test('cursor at end', () => {
const query = 'RETURN -1, -2, -3';
const result = formatQuery(query, query.length - 1);
expect(result.newCursorPos).toEqual(result.formattedString.length - 1);
});
test('cursor at newline', () => {
const query = `MATCH (n:Person)
WHERE n.name = "Steve"
RETURN n
LIMIT 12;`;
const result = formatQuery(query, 56);
expect(result.newCursorPos).toEqual(54);
});

test('cursor start of line with spaces newline', () => {
const query = `UNWIND range(1,100) as _
CALL {
MATCH (source:object) WHERE source.id= $id1
MATCH (target:object) WHERE target.id= $id2
MATCH path = (source)-[*1..10]->(target)
WITH path, reduce(weight = 0, r IN relationships(path) | weight + r.weight) as Weight
ORDER BY Weight LIMIT 3
RETURN length(path) as l, Weight
}
RETURN count(*)`;
const result = formatQuery(query, 124);
expect(result.newCursorPos).toEqual(131);
});

test('cursor start of line without spaces', () => {
const query = `MATCH (variable :Label)-[:REL_TYPE]->()
WHERE variable.property = "String"
OR namespaced.function() = false
// comment
OR $parameter > 2
RETURN variable;`;
const result = formatQuery(query, 133);
expect(result.newCursorPos).toEqual(120);
});
});
17 changes: 14 additions & 3 deletions packages/react-codemirror-playground/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DbSchema, testData } from '@neo4j-cypher/language-support';
import { CypherEditor } from '@neo4j-cypher/react-codemirror';
import { useMemo, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { Tree } from 'react-d3-tree';
import { TokenTable } from './TokenTable';
import { getDebugTree } from './treeUtil';
Expand Down Expand Up @@ -46,6 +46,8 @@ export function App() {
);
const [schemaError, setSchemaError] = useState<string | null>(null);

const editorRef = useRef<CypherEditor>(null);

const treeData = useMemo(() => {
return getDebugTree(value);
}, [value]);
Expand Down Expand Up @@ -103,6 +105,7 @@ export function App() {
<CypherEditor
className="border-2 border-gray-100 dark:border-gray-400 text-sm"
value={value}
ref={editorRef}
onChange={setValue}
prompt="neo4j$"
onExecute={() => {
Expand All @@ -112,10 +115,18 @@ export function App() {
theme={darkMode ? 'dark' : 'light'}
history={Object.values(demos)}
schema={schema}
featureFlags={{ signatureInfoOnAutoCompletions: true }}
featureFlags={{
signatureInfoOnAutoCompletions: true,
}}
ariaLabel="Cypher Editor"
/>

<p
onClick={() => editorRef.current.format()}
className="text-blue-500 cursor-pointer hover:text-blue-700"
title={window.navigator.userAgent.includes("Mac") ? "Shift-Option-F" : "Ctrl-Shift-I"}
>
Format Query
</p>
{commandRanCount > 0 && (
<span className="text-gray-400">
"commands" ran so far: {commandRanCount}
Expand Down
32 changes: 30 additions & 2 deletions packages/react-codemirror/src/CypherEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
placeholder,
ViewUpdate,
} from '@codemirror/view';
import { type DbSchema } from '@neo4j-cypher/language-support';
import { formatQuery, type DbSchema } from '@neo4j-cypher/language-support';
import debounce from 'lodash.debounce';
import { Component, createRef } from 'react';
import { DEBOUNCE_TIME } from './constants';
Expand Down Expand Up @@ -195,7 +195,6 @@ const executeKeybinding = (
run: insertNewline,
},
};

if (onExecute) {
keybindings['Ctrl-Enter'] = {
key: 'Ctrl-Enter',
Expand Down Expand Up @@ -276,6 +275,26 @@ export class CypherEditor extends Component<
editorView: React.MutableRefObject<EditorView> = createRef();
private schemaRef: React.MutableRefObject<CypherConfig> = createRef();

/**
* Format Cypher query
*/
format() {
const currentView = this.editorView.current;
const doc = currentView.state.doc.toString();
const { formattedString, newCursorPos } = formatQuery(
doc,
currentView.state.selection.main.anchor,
);
currentView.dispatch({
changes: {
from: 0,
to: doc.length,
insert: formattedString,
},
selection: { anchor: newCursorPos },
});
}

/**
* Focus the editor
*/
Expand Down Expand Up @@ -382,6 +401,15 @@ export class CypherEditor extends Component<
}),
]
: [];
extraKeybindings.push({
key: 'Ctrl-Shift-f',
mac: 'Alt-Shift-f',
preventDefault: true,
run: () => {
this.format();
return true;
},
});

this.editorState.current = EditorState.create({
extensions: [
Expand Down

0 comments on commit b3f1b78

Please sign in to comment.