diff --git a/packages/language-support/src/formatting/formatting.ts b/packages/language-support/src/formatting/formatting.ts index 45853ae88..7cc2e8993 100644 --- a/packages/language-support/src/formatting/formatting.ts +++ b/packages/language-support/src/formatting/formatting.ts @@ -24,6 +24,7 @@ import { } from '../generated-parser/CypherCmdParser'; import CypherCmdParserVisitor from '../generated-parser/CypherCmdParserVisitor'; import { + findTargetToken, getParseTreeAndTokens, handleMergeClause, isComment, @@ -41,6 +42,8 @@ export class TreePrintVisitor extends CypherCmdParserVisitor { buffer: string[] = []; indentation = 0; indentationSpaces = 2; + targetToken?: number; + cursorPos?: number; constructor(private tokenStream: CommonTokenStream) { super(); @@ -191,14 +194,19 @@ export class TreePrintVisitor extends CypherCmdParserVisitor { 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(); } @@ -223,6 +231,9 @@ export class TreePrintVisitor extends CypherCmdParserVisitor { 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); }; @@ -372,8 +383,47 @@ export class TreePrintVisitor extends CypherCmdParserVisitor { }; } -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, + }; } diff --git a/packages/language-support/src/formatting/formattingHelpers.ts b/packages/language-support/src/formatting/formattingHelpers.ts index ed0973bf9..b9e8cfcca 100644 --- a/packages/language-support/src/formatting/formattingHelpers.ts +++ b/packages/language-support/src/formatting/formattingHelpers.ts @@ -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; +} diff --git a/packages/language-support/src/index.ts b/packages/language-support/src/index.ts index 959b262c6..847e904f3 100644 --- a/packages/language-support/src/index.ts +++ b/packages/language-support/src/index.ts @@ -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' diff --git a/packages/language-support/src/tests/formatting/formatting.test.ts b/packages/language-support/src/tests/formatting/formatting.test.ts index a2d31578f..1a3c3ad07 100644 --- a/packages/language-support/src/tests/formatting/formatting.test.ts +++ b/packages/language-support/src/tests/formatting/formatting.test.ts @@ -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); + }); +}); diff --git a/packages/react-codemirror-playground/src/App.tsx b/packages/react-codemirror-playground/src/App.tsx index f39abbdd1..59ab690d6 100644 --- a/packages/react-codemirror-playground/src/App.tsx +++ b/packages/react-codemirror-playground/src/App.tsx @@ -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'; @@ -46,6 +46,8 @@ export function App() { ); const [schemaError, setSchemaError] = useState(null); + const editorRef = useRef(null); + const treeData = useMemo(() => { return getDebugTree(value); }, [value]); @@ -103,6 +105,7 @@ export function App() { { @@ -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" /> - +

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 +

{commandRanCount > 0 && ( "commands" ran so far: {commandRanCount} diff --git a/packages/react-codemirror/src/CypherEditor.tsx b/packages/react-codemirror/src/CypherEditor.tsx index e5e63156c..c2792d3ab 100644 --- a/packages/react-codemirror/src/CypherEditor.tsx +++ b/packages/react-codemirror/src/CypherEditor.tsx @@ -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'; @@ -195,7 +195,6 @@ const executeKeybinding = ( run: insertNewline, }, }; - if (onExecute) { keybindings['Ctrl-Enter'] = { key: 'Ctrl-Enter', @@ -276,6 +275,26 @@ export class CypherEditor extends Component< editorView: React.MutableRefObject = createRef(); private schemaRef: React.MutableRefObject = 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 */ @@ -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: [