diff --git a/packages/language-support/src/formatting/formattingHelpersv2.ts b/packages/language-support/src/formatting/formattingHelpersv2.ts index c4edbe27..dbfc81c2 100644 --- a/packages/language-support/src/formatting/formattingHelpersv2.ts +++ b/packages/language-support/src/formatting/formattingHelpersv2.ts @@ -48,6 +48,7 @@ export const MAX_COL = 80; export interface BaseChunk { isCursor?: boolean; + doubleBreak?: true; text: string; groupsStarting: number; groupsEnding: number; diff --git a/packages/language-support/src/formatting/formattingSolutionSearchv2.ts b/packages/language-support/src/formatting/formattingSolutionSearchv2.ts index 0e845005..b49aeee6 100644 --- a/packages/language-support/src/formatting/formattingSolutionSearchv2.ts +++ b/packages/language-support/src/formatting/formattingSolutionSearchv2.ts @@ -21,7 +21,7 @@ const INDENTATION = 2; const showGroups = false; export interface Split { - splitType: ' ' | '\n' | ''; + splitType: ' ' | '' | '\n' | '\n\n'; cost: number; } @@ -74,6 +74,35 @@ type FinalResult = string | FinalResultWithPos; const openingCharacters = [CypherCmdLexer.LPAREN, CypherCmdLexer.LBRACKET]; +const standardSplits: Split[] = [ + { splitType: ' ', cost: 0 }, + { splitType: '\n', cost: 1 }, +]; +const doubleBreakStandardSplits: Split[] = [ + { splitType: ' ', cost: 0 }, + { splitType: '\n\n', cost: 1 }, +]; +const noSpaceSplits: Split[] = [ + { splitType: '', cost: 0 }, + { splitType: '\n', cost: 1 }, +]; +const noSpaceDoubleBreakSplits: Split[] = [ + { splitType: '', cost: 0 }, + { splitType: '\n\n', cost: 1 }, +]; +const noBreakSplit: Split[] = [{ splitType: ' ', cost: 0 }]; +const noSpaceNoBreakSplit: Split[] = [{ splitType: '', cost: 0 }]; +const onlyBreakSplit: Split[] = [{ splitType: '\n', cost: 0 }]; +const onlyDoubleBreakSplit: Split[] = [{ splitType: '\n\n', cost: 0 }]; + +const emptyChunk: RegularChunk = { + type: 'REGULAR', + text: '', + groupsStarting: 0, + groupsEnding: 0, + modifyIndentation: 0, +}; + export function doesNotWantSpace(chunk: Chunk, nextChunk: Chunk): boolean { return ( nextChunk?.type !== 'COMMENT' && @@ -110,7 +139,7 @@ function stateToString(state: State) { } function getNeighbourState(curr: State, choice: Choice, split: Split): State { - const isBreak = split.splitType === '\n'; + const isBreak = split.splitType === '\n' || split.splitType === '\n\n'; // A state has indentation, which is applied after a hard line break. However, if it has an // active group and we decided to split within a line, the alignment of that group takes precedence // over the base indentation. @@ -282,7 +311,10 @@ function decisionsToFormatted(decisions: Decision[]): FinalResult { if (showGroups) addGroupEnd(buffer, decision); buffer.push(decision.chosenSplit.splitType); }); - const result = buffer.join('').trimEnd(); + let result = buffer.join('').trimEnd(); + if (decisions.at(-1).left.doubleBreak) { + result += '\n'; + } if (cursorPos === -1) { return result; } @@ -291,16 +323,24 @@ function decisionsToFormatted(decisions: Decision[]): FinalResult { function determineSplits(chunk: Chunk, nextChunk: Chunk): Split[] { if (isCommentBreak(chunk, nextChunk)) { - return onlyBreakSplit; + return chunk.doubleBreak ? onlyDoubleBreakSplit : onlyBreakSplit; } if (chunk.type === 'REGULAR') { - if (doesNotWantSpace(chunk, nextChunk) && chunk.noBreak) - return noSpaceNoBreakSplit; - if (doesNotWantSpace(chunk, nextChunk)) return noSpaceSplits; - if (chunk.noBreak) return noBreakSplit; + const noSpace = doesNotWantSpace(chunk, nextChunk); + + if (noSpace) { + if (chunk.noBreak) { + return noSpaceNoBreakSplit; + } + return chunk.doubleBreak ? noSpaceDoubleBreakSplits : noSpaceSplits; + } + if (chunk.noBreak) { + return noBreakSplit; + } } - return standardSplits; + + return chunk.doubleBreak ? doubleBreakStandardSplits : standardSplits; } function chunkListToChoices(chunkList: Chunk[]): Choice[] { @@ -347,23 +387,3 @@ export function buffersToFormattedString( } return { formattedString: formatted.trimEnd(), cursorPos: cursorPos }; } - -const standardSplits: Split[] = [ - { splitType: ' ', cost: 0 }, - { splitType: '\n', cost: 1 }, -]; -const noSpaceSplits: Split[] = [ - { splitType: '', cost: 0 }, - { splitType: '\n', cost: 1 }, -]; -const noBreakSplit: Split[] = [{ splitType: ' ', cost: 0 }]; -const noSpaceNoBreakSplit: Split[] = [{ splitType: '', cost: 0 }]; -const onlyBreakSplit: Split[] = [{ splitType: '\n', cost: 0 }]; - -const emptyChunk: RegularChunk = { - type: 'REGULAR', - text: '', - groupsStarting: 0, - groupsEnding: 0, - modifyIndentation: 0, -}; diff --git a/packages/language-support/src/formatting/formattingv2.ts b/packages/language-support/src/formatting/formattingv2.ts index 7d4b4bb9..db06e008 100644 --- a/packages/language-support/src/formatting/formattingv2.ts +++ b/packages/language-support/src/formatting/formattingv2.ts @@ -141,6 +141,7 @@ export class TreePrintVisitor extends CypherCmdParserVisitor { const chunk: RegularChunk = { type: 'REGULAR', text: prefix.text + suffix.text, + doubleBreak: suffix.doubleBreak, groupsStarting: prefix.groupsStarting + suffix.groupsStarting, groupsEnding: prefix.groupsEnding + suffix.groupsEnding, modifyIndentation: prefix.modifyIndentation + suffix.modifyIndentation, @@ -180,6 +181,21 @@ export class TreePrintVisitor extends CypherCmdParserVisitor { this.setAvoidProperty('noBreak'); }; + doubleBreakBetween = (): void => { + if (this.currentBuffer().length > 0) { + this.currentBuffer().at(-1).doubleBreak = true; + } + }; + + doubleBreakBetweenNonComment = (): void => { + if ( + this.currentBuffer().length > 0 && + this.currentBuffer().at(-1).type !== 'COMMENT' + ) { + this.currentBuffer().at(-1).doubleBreak = true; + } + }; + getFirstNonCommentIdx = (): number => { let idx = this.currentBuffer().length - 1; while (idx >= 0 && this.currentBuffer()[idx].type === 'COMMENT') { @@ -213,6 +229,53 @@ export class TreePrintVisitor extends CypherCmdParserVisitor { this.currentBuffer().at(idx).modifyIndentation -= 1; }; + findBottomChild = ( + ctx: ParserRuleContext | TerminalNode, + side: 'before' | 'after', + ): TerminalNode => { + if (ctx instanceof TerminalNode) { + return ctx; + } + const idx = side === 'before' ? 0 : ctx.getChildCount() - 1; + const child = ctx.getChild(idx); + if (child instanceof TerminalNode) { + return child; + } else if (child instanceof ParserRuleContext) { + return this.findBottomChild(child, side); + } + throw new Error('Internal formatting error in findBottomChild'); + }; + + preserveExplicitNewlineAfter = (ctx: ParserRuleContext) => { + this.preserveExplicitNewline(ctx, 'after'); + }; + + preserveExplicitNewlineBefore = (ctx: ParserRuleContext) => { + this.preserveExplicitNewline(ctx, 'before'); + }; + + preserveExplicitNewline = ( + ctx: ParserRuleContext, + side: 'before' | 'after', + ) => { + const bottomChild = this.findBottomChild(ctx, side); + const token = bottomChild.symbol; + const hiddenTokens = + side === 'before' + ? this.tokenStream.getHiddenTokensToLeft(token.tokenIndex) + : this.tokenStream.getHiddenTokensToRight(token.tokenIndex); + const hiddenNewlines = hiddenTokens?.filter( + (token) => token.text === '\n', + ).length; + const commentCount = hiddenTokens?.filter((token) => + isComment(token), + ).length; + // If there are comments, they take responsibility of the explicit newlines. + if (hiddenNewlines > 1 && commentCount === 0) { + this.doubleBreakBetweenNonComment(); + } + }; + // Comments are in the hidden channel, so grab them manually addCommentsBefore = (node: TerminalNode) => { const token = node.symbol; @@ -243,11 +306,22 @@ export class TreePrintVisitor extends CypherCmdParserVisitor { const hiddenTokens = this.tokenStream.getHiddenTokensToRight( token.tokenIndex, ); - const commentTokens = (hiddenTokens || []).filter((token) => - isComment(token), - ); const nodeLine = node.symbol.line; - for (const commentToken of commentTokens) { + let breakCount = 0; + let includesComment = false; + for (const hiddenToken of hiddenTokens || []) { + if (hiddenToken.text === '\n') { + breakCount++; + } + if (!isComment(hiddenToken)) { + continue; + } + if (breakCount > 1) { + this.doubleBreakBetween(); + } + breakCount = 0; + const commentToken = hiddenToken; + includesComment = true; const text = commentToken.text.trim(); const commentLine = commentToken.line; const chunk: CommentChunk = { @@ -269,6 +343,11 @@ export class TreePrintVisitor extends CypherCmdParserVisitor { } this.currentBuffer().push(chunk); } + // Account for the last comment having multiple newline after it, to remember explicit + // newlines when we have e.g. [C, \n, \n] + if (breakCount > 1 && includesComment) { + this.doubleBreakBetween(); + } }; visitIfNotNull = (ctx: ParserRuleContext | TerminalNode) => { @@ -283,11 +362,25 @@ export class TreePrintVisitor extends CypherCmdParserVisitor { } }; + visitStatementsOrCommands = (ctx: StatementsOrCommandsContext) => { + const n = ctx.statementOrCommand_list().length; + for (let i = 0; i < n; i++) { + this.visit(ctx.statementOrCommand(i)); + if (i < n - 1 || ctx.SEMICOLON(i)) { + if (this.currentBuffer().at(-1).text === '\n') { + this.currentBuffer().pop(); + } + this.visit(ctx.SEMICOLON(i)); + } + } + }; + // Handled separately because clauses should start on new lines, see // https://neo4j.com/docs/cypher-manual/current/styleguide/#cypher-styleguide-indentation-and-line-breaks visitClause = (ctx: ClauseContext) => { this.breakLine(); this.visitChildren(ctx); + this.preserveExplicitNewlineAfter(ctx); }; visitWithClause = (ctx: WithClauseContext) => { @@ -355,6 +448,7 @@ export class TreePrintVisitor extends CypherCmdParserVisitor { }; visitLimit = (ctx: LimitContext) => { + this.preserveExplicitNewlineBefore(ctx); this.breakLine(); this.visitChildren(ctx); }; @@ -776,6 +870,7 @@ export class TreePrintVisitor extends CypherCmdParserVisitor { // Handled separately because where is not a clause (it is a subclause) visitWhereClause = (ctx: WhereClauseContext) => { + this.preserveExplicitNewlineBefore(ctx); this.breakLine(); this.visit(ctx.WHERE()); this.avoidBreakBetween(); diff --git a/packages/language-support/src/tests/formatting/formattingv2.test.ts b/packages/language-support/src/tests/formatting/formattingv2.test.ts index 120d5328..53e4cff1 100644 --- a/packages/language-support/src/tests/formatting/formattingv2.test.ts +++ b/packages/language-support/src/tests/formatting/formattingv2.test.ts @@ -467,19 +467,12 @@ LIMIT 0`; test('call with IN CONCURRENT... at the end', () => { const query = `MATCH (c:Cuenta)-[:REALIZA]->(m:Movimiento)-[:HACIA]->(c2:Cuenta) - WHERE NOT EXISTS {MATCH (c)-[:TRANSFIERE]->(c2)} - WITH c, c2, count(m) as trxs, avg(m.monto) as avgTrx, sum(m.monto) as totalSum LIMIT 1000 - CALL (c, c2, trxs, avgTrx, totalSum) { - MERGE (c)-[r:TRANSFIERE]->(c2) - ON CREATE SET r.totalTrx = trxs, r.avgTrx = avgTrx, r.total = totalSum - ON MATCH SET r.totalTrx = trxs, r.avgTrx = avgTrx, r.total = totalSum - } IN 10 CONCURRENT TRANSACTIONS OF 25 ROWS ;`; @@ -1324,6 +1317,7 @@ CREATE (qwer_tyuiopa_zxcvbnmasdfg)-[:abcdefgh]->(qwertyu), (mnbvcxzasdfghj_poiuytrewq)-[:GHIJKLMN]->(zxcvbnmlkjhgfd_asdfjkl), (zxcvbnmlkjhgfd_asdfjkl)-[:OPQRS_TU]->(qwertyu), (qwert_yuiopasdfg)-[:OPQRS_TU]->(qwertyu), + // this is a loooooooooooooooooooong comment (hjklmno)-[:OPQRS_TU]->(zxcvbn_mnb_lkjhgfdsa), (zxcvbn_mnb_lkjhgfdsa)-[:OPQRS_TU]->(poiuzxcv), @@ -1331,9 +1325,11 @@ CREATE (qwer_tyuiopa_zxcvbnmasdfg)-[:abcdefgh]->(qwertyu), (asdfghjk_qwe)-[:OPQRS_TU]->(zxcvbnmop), (zxcvbnmop)-[:OPQRS_TU]->(qwertyu), (zxcvbnmop)-[:VWXYZABC]->(qwertyuiopa_sdfghjklz), + // this is a loooooooooooooooooooong comment (mnbvcxzlkj)-[:VWXYZABC]->(asdfg_hjkltyui), (mnbvcxzlkj)-[:VWXYZABC]->(qwertyuiopa_sdfghjklz), + // this is a loooooooooooooooooooong comment (mnbvcxzasdfghj_poiuytrewq)-[:YZABCDF]->(asdfghj_klzxcvbnmop), (mnbvcxzasdfghj_poiuytrewq)-[:DEFHIJKL]->(qazwsxedc_rfvgt), @@ -1631,3 +1627,510 @@ LIMIT "6pkMe6Kx"`; verifyFormatting(query, expected); }); }); + +describe('tests for respcecting user line breaks', () => { + test('multiple clauses', () => { + const query = `MATCH (move:Item) WHERE move.name = $move +OPTIONAL MATCH (insertBefore:Item) WHERE insertBefore.name = $insertBefore + +WITH move, insertBefore +WHERE (insertBefore IS NULL OR move <> insertBefore) AND + (NOT EXISTS {(move)-[:NEXT]->(insertBefore)}) AND + (insertBefore IS NOT NULL OR EXISTS{(move)-[:NEXT]->()}) + +OPTIONAL MATCH (beforeMove:Item)-[relBeforeMove:NEXT]->(move) +OPTIONAL MATCH (move)-[relAfterMove:NEXT]->(afterMove:Item) + +DELETE relBeforeMove +DELETE relAfterMove + +WITH move, insertBefore, beforeMove, afterMove +OPTIONAL MATCH (insertAfter:Item)-[oldRel:NEXT]->(insertBefore) + +DELETE oldRel + +WITH move, insertBefore, insertAfter, beforeMove, afterMove +CALL(beforeMove, afterMove) { + WITH * + WHERE beforeMove IS NOT NULL AND afterMove IS NOT NULL + CREATE (beforeMove)-[:NEXT]->(afterMove) +} + +CALL(move, insertBefore, insertAfter) { + WITH * + WHERE insertBefore IS NULL // insertAfter will always be NULL in this case + MATCH (lastElement:Item) + WHERE NOT (lastElement)-[:NEXT]->() AND lastElement <> move + CREATE (lastElement)-[:NEXT]->(move) +} +CALL(move, insertBefore, insertAfter) { + WITH * + WHERE insertAfter IS NULL AND insertBefore IS NOT NULL + CREATE (move)-[:NEXT]->(insertBefore) +} +CALL(move, insertBefore, insertAfter) { + WITH * + WHERE insertAfter IS NOT NULL AND insertBefore IS NOT NULL + CREATE (insertAfter)-[:NEXT]->(move) + CREATE (move)-[:NEXT]->(insertBefore) +}`; + const expected = `MATCH (move:Item) +WHERE move.name = $move +OPTIONAL MATCH (insertBefore:Item) +WHERE insertBefore.name = $insertBefore + +WITH move, insertBefore +WHERE (insertBefore IS NULL OR move <> insertBefore) AND + (NOT EXISTS { (move)-[:NEXT]->(insertBefore) }) AND + (insertBefore IS NOT NULL OR EXISTS { (move)-[:NEXT]->() }) + +OPTIONAL MATCH (beforeMove:Item)-[relBeforeMove:NEXT]->(move) +OPTIONAL MATCH (move)-[relAfterMove:NEXT]->(afterMove:Item) + +DELETE relBeforeMove +DELETE relAfterMove + +WITH move, insertBefore, beforeMove, afterMove +OPTIONAL MATCH (insertAfter:Item)-[oldRel:NEXT]->(insertBefore) + +DELETE oldRel + +WITH move, insertBefore, insertAfter, beforeMove, afterMove +CALL (beforeMove, afterMove) { + WITH * + WHERE beforeMove IS NOT NULL AND afterMove IS NOT NULL + CREATE (beforeMove)-[:NEXT]->(afterMove) +} + +CALL (move, insertBefore, insertAfter) { + WITH * + WHERE insertBefore IS NULL // insertAfter will always be NULL in this case + MATCH (lastElement:Item) + WHERE NOT (lastElement)-[:NEXT]->() AND lastElement <> move + CREATE (lastElement)-[:NEXT]->(move) +} +CALL (move, insertBefore, insertAfter) { + WITH * + WHERE insertAfter IS NULL AND insertBefore IS NOT NULL + CREATE (move)-[:NEXT]->(insertBefore) +} +CALL (move, insertBefore, insertAfter) { + WITH * + WHERE insertAfter IS NOT NULL AND insertBefore IS NOT NULL + CREATE (insertAfter)-[:NEXT]->(move) + CREATE (move)-[:NEXT]->(insertBefore) +}`; + verifyFormatting(query, expected); + }); + + test('multiple clauses with comments', () => { + const query = ` +// Find the cell to move and the cell to insert it before (which may be null to insert at the end of the list) +MATCH (move:Item) WHERE move.name = $move +OPTIONAL MATCH (insertBefore:Item) WHERE insertBefore.name = $insertBefore + +// If we ask to have it placed at the same position, don't do anything +WITH move, insertBefore +WHERE (insertBefore IS NULL OR move <> insertBefore) AND + (NOT EXISTS {(move)-[:NEXT]->(insertBefore)}) AND + (insertBefore IS NOT NULL OR EXISTS{(move)-[:NEXT]->()}) + +// Find the items before and after the item to move (if they exist) +OPTIONAL MATCH (beforeMove:Item)-[relBeforeMove:NEXT]->(move) +OPTIONAL MATCH (move)-[relAfterMove:NEXT]->(afterMove:Item) + +// Disconnect the item to move +DELETE relBeforeMove +DELETE relAfterMove + +// Now locate the item to insert it after (if any) +WITH move, insertBefore, beforeMove, afterMove +OPTIONAL MATCH (insertAfter:Item)-[oldRel:NEXT]->(insertBefore) + +// Delete the old link to make place for the moved item +DELETE oldRel + +// Now patch up the link where the item was removed (unless in start or end) +WITH move, insertBefore, insertAfter, beforeMove, afterMove +CALL(beforeMove, afterMove) { + WITH * + WHERE beforeMove IS NOT NULL AND afterMove IS NOT NULL + CREATE (beforeMove)-[:NEXT]->(afterMove) +} + +// Now we need to insert the moved item, but how we do that depends on where it goes +CALL(move, insertBefore, insertAfter) { + WITH * + WHERE insertBefore IS NULL // insertAfter will always be NULL in this case + MATCH (lastElement:Item) + WHERE NOT (lastElement)-[:NEXT]->() AND lastElement <> move + CREATE (lastElement)-[:NEXT]->(move) +} +CALL(move, insertBefore, insertAfter) { + WITH * + WHERE insertAfter IS NULL AND insertBefore IS NOT NULL + CREATE (move)-[:NEXT]->(insertBefore) +} +CALL(move, insertBefore, insertAfter) { + WITH * + WHERE insertAfter IS NOT NULL AND insertBefore IS NOT NULL + CREATE (insertAfter)-[:NEXT]->(move) + CREATE (move)-[:NEXT]->(insertBefore) +}`; + const expected = `// Find the cell to move and the cell to insert it before (which may be null to insert at the end of the list) +MATCH (move:Item) +WHERE move.name = $move +OPTIONAL MATCH (insertBefore:Item) +WHERE insertBefore.name = $insertBefore + +// If we ask to have it placed at the same position, don't do anything +WITH move, insertBefore +WHERE (insertBefore IS NULL OR move <> insertBefore) AND + (NOT EXISTS { (move)-[:NEXT]->(insertBefore) }) AND + (insertBefore IS NOT NULL OR EXISTS { (move)-[:NEXT]->() }) + +// Find the items before and after the item to move (if they exist) +OPTIONAL MATCH (beforeMove:Item)-[relBeforeMove:NEXT]->(move) +OPTIONAL MATCH (move)-[relAfterMove:NEXT]->(afterMove:Item) + +// Disconnect the item to move +DELETE relBeforeMove +DELETE relAfterMove + +// Now locate the item to insert it after (if any) +WITH move, insertBefore, beforeMove, afterMove +OPTIONAL MATCH (insertAfter:Item)-[oldRel:NEXT]->(insertBefore) + +// Delete the old link to make place for the moved item +DELETE oldRel + +// Now patch up the link where the item was removed (unless in start or end) +WITH move, insertBefore, insertAfter, beforeMove, afterMove +CALL (beforeMove, afterMove) { + WITH * + WHERE beforeMove IS NOT NULL AND afterMove IS NOT NULL + CREATE (beforeMove)-[:NEXT]->(afterMove) +} + +// Now we need to insert the moved item, but how we do that depends on where it goes +CALL (move, insertBefore, insertAfter) { + WITH * + WHERE insertBefore IS NULL // insertAfter will always be NULL in this case + MATCH (lastElement:Item) + WHERE NOT (lastElement)-[:NEXT]->() AND lastElement <> move + CREATE (lastElement)-[:NEXT]->(move) +} +CALL (move, insertBefore, insertAfter) { + WITH * + WHERE insertAfter IS NULL AND insertBefore IS NOT NULL + CREATE (move)-[:NEXT]->(insertBefore) +} +CALL (move, insertBefore, insertAfter) { + WITH * + WHERE insertAfter IS NOT NULL AND insertBefore IS NOT NULL + CREATE (insertAfter)-[:NEXT]->(move) + CREATE (move)-[:NEXT]->(insertBefore) +}`; + verifyFormatting(query, expected); + }); + + test('simplest possible match return example', () => { + const query = ` +MATCH (n) + +RETURN n`; + const expected = ` +MATCH (n) + +RETURN n`.trimStart(); + verifyFormatting(query, expected); + }); + + test('simplest possible match return example with more than one break', () => { + const query = ` +MATCH (n) + + + + + +RETURN n`; + const expected = ` +MATCH (n) + +RETURN n`.trimStart(); + verifyFormatting(query, expected); + }); + + test('this comment should leave a newline before it', () => { + const query = ` +MATCH (n) + +// Comment +RETURN n`.trimStart(); + const expected = query; + verifyFormatting(query, expected); + }); + + test('another long example with clauses and comments', () => { + const query = `MERGE (qwerty:Abcdef {name: "ABCDEFGH"}) +// Xyzzab qwe POIUYTREWQ poiuy rty uio MNBVCXZ +MERGE (A1B2C3D4E5:Qwert {name: "IJKLMNOP"}) +MERGE (A1B2C3D4E5)-[:QAZWSXEDCR]->(:Zxcvbn {name: "abcdefgh"}) +MERGE (A1B2C3D4E5)-[:QAZWSXEDCR]->(:Zxcvbn {name: "ijklmnop"}) +MERGE (A1B2C3D4E5)-[:QAZWSXEDCR]->(:Zxcvbn {name: "qrstuvwx"}) +MERGE (A1B2C3D4E5)-[:QAZWSXEDCR]->(:Zxcvbn {name: "yzABCDEF"}) +MERGE (A1B2C3D4E5)-[:QAZWSXEDCR]->(:Zxcvbn {name: "GHIJKLMN"}) +MERGE (A1B2C3D4E5)-[:QAZWSXEDCR]->(:Zxcvbn {name: "opqrstuv"}) +MERGE (A1B2C3D4E5)-[:QAZWSXEDCR]->(:Zxcvbn {name: "wxyz0123"}) +MERGE (A1B2C3D4E5)-[:QAZWSXEDCR]->(:Zxcvbn {name: "456789ab"}) +MERGE (A1B2C3D4E5)-[:QAZWSXEDCR]->(:Zxcvbn {name: "cdefghij"}) +MERGE (A1B2C3D4E5)-[:QAZWSXEDCR]->(:Zxcvbn {name: "klmnopqr"}) +MERGE (A1B2C3D4E5)-[:QAZWSXEDCR]->(:Zxcvbn {name: "stuvwxyz"}) +MERGE (qwerty)-[:PLMKOIJNBHUY]->(A1B2C3D4E5) + +// Abcdef ghi ZxcvbnmQwertyz uiopa sdx fgh jklzxcv + +MERGE (F6G7H8J9K0L1M2:Qwert {name: "ZXCVBNML"}) +MERGE (F6G7H8J9K0L1M2)-[:QAZWSXEDCR]->(:Zxcvbn {name: "asdfghjk"}) +MERGE (F6G7H8J9K0L1M2)-[:QAZWSXEDCR]->(:Zxcvbn {name: "poiuytre"}) +MERGE (qwerty)-[:PLMKOIJNBHUY]->(F6G7H8J9K0L1M2) + +MERGE (ZyXwVuTsr:Qwert {name: "lkjhgfds"}) +MERGE (ZyXwVuTsr)-[:QAZWSXEDCR]->(:Zxcvbn {name: "mnbvcxza"}) +MERGE (ZyXwVuTsr)-[:QAZWSXEDCR]->(:Zxcvbn {name: "qwertyui"}) +MERGE (qwerty)-[:PLMKOIJNBHUY]->(ZyXwVuTsr) + +// Fghijk lmn QWERTYUIOPASDFGHJ KJIHG QAZ WSX EDCRFVT +MERGE (QWERTYUIOPASDFGHJ:Qwert {name: "1234abcd"}) +MERGE (QWERTYUIOPASDFGHJ)-[:QAZWSXEDCR]->(:Zxcvbn {name: "efghijkl"}) +MERGE (QWERTYUIOPASDFGHJ)-[:QAZWSXEDCR]->(:Zxcvbn {name: "mnopqrst"}) +MERGE (QWERTYUIOPASDFGHJ)-[:QAZWSXEDCR]->(:Zxcvbn {name: "uvwxYZ12"}) +MERGE (QWERTYUIOPASDFGHJ)-[:QAZWSXEDCR]->(:Zxcvbn {name: "34567890"}) +MERGE (qwerty)-[:PLMKOIJNBHUY]->(QWERTYUIOPASDFGHJ) + +// Lmnopq rst UVWXYZABCDEFG NOPQR STU VWX YZABCDF + +MERGE (LMNOPQRSTUVWX:Qwert {name: "zxvbnmlk"}) +MERGE (LMNOPQRSTUVWX)-[:QAZWSXEDCR]->(:Zxcvbn {name: "opaslkdj"}) +MERGE (LMNOPQRSTUVWX)-[:QAZWSXEDCR]->(:Zxcvbn {name: "qwerty12"}) +MERGE (qwerty)-[:PLMKOIJNBHUY]->(LMNOPQRSTUVWX) + +// uvwxyz efg lmno pqrstuvwxyzab + +MERGE (pqr45:Qwer {name: "asdf1234", type: "zxcv5678"}) +MERGE (LMNOPQRSTUVWX)-[:ZXCVB]->(pqr45)-[:ZXCVB]->(A1B2C3D4E5) +MERGE (qwerty)-[:ASDFGHJKL]->(pqr45) +MERGE (stu78:Qwer {name: "poiuy987", type: "lkjh6543"}) +MERGE (A1B2C3D4E5)-[:ZXCVB]->(stu78)-[:ZXCVB]->(F6G7H8J9K0L1M2) +MERGE (qwerty)-[:ASDFGHJKL]->(stu78) +MERGE (vwx90:Qwer {name: "mnbv3210", type: "zazxswed"}) +MERGE (A1B2C3D4E5)-[:ZXCVB]->(vwx90)-[:ZXCVB]->(ZyXwVuTsr) +MERGE (qwerty)-[:ASDFGHJKL]->(vwx90)`; + const expected = query; + verifyFormatting(query, expected); + }); + + test('double newline before block comment', () => { + const query = ` +MATCH (a) + + +/* block comment */ +RETURN a`; + const expected = ` +MATCH (a) + +/* block comment */ +RETURN a`.trimStart(); + verifyFormatting(query, expected); + }); + + test('inline block comment with internal newlines', () => { + const query = ` +MATCH (a) +RETURN a; /* comment line 1 + +comment line 2 */ +MATCH (b) +RETURN b;`; + const expected = ` +MATCH (a) +RETURN a; /* comment line 1 + +comment line 2 */ +MATCH (b) +RETURN b;`.trimStart(); + verifyFormatting(query, expected); + }); + + test('multiline block comment with line before it', () => { + const query = ` +MATCH (a) +RETURN a; + +/* comment line 1 + + +comment line 3 */ +MATCH (b) +RETURN b;`; + const expected = ` +MATCH (a) +RETURN a; + +/* comment line 1 + + +comment line 3 */ +MATCH (b) +RETURN b;`.trimStart(); + verifyFormatting(query, expected); + }); + + test('mixed comments with explicit newline', () => { + const query = ` +MATCH (a) +// single line comment + + +/* block comment */ +RETURN a`.trimStart(); + const expected = ` +MATCH (a) +// single line comment + +/* block comment */ +RETURN a`.trimStart(); + verifyFormatting(query, expected); + }); + + test('should remove the first but not the second newline, and keep indentation', () => { + const query = ` +MERGE (a:Person {name: "Alice"}) + +ON CREATE SET a.created = timestamp() +ON MATCH SET a.lastSeen = timestamp() + +RETURN a +`.trimStart(); + const expected = ` +MERGE (a:Person {name: "Alice"}) + ON CREATE SET a.created = timestamp() + ON MATCH SET a.lastSeen = timestamp() + +RETURN a`.trimStart(); + verifyFormatting(query, expected); + }); + + test('this query should not lose idempotency because of double break and concatenate', () => { + const query = `MATCH (a:AbCdEf {XyZ012345: "ABCDEFGH"}) + +MATCH (a)-[b:ZxCvBnMq]->(d:GhIjKlM)<-[e1:ZxCvBnMq]-(zYxWvUtSrqP:AbCdEf) +WHERE ( + // abcdefghijklmnopqrstuvwxy + (b.AbCdEfGhIjKlMn <= "1A2b3C4d") + + // Qwertyuiopasdfghjklzxcvbnm1234567890AB + OR ( + e1.OpQrStUvW >= "Z9y8X7w6" + AND b.AbCdEfGhIjKlMn in ["aBcDeFgH","IjKlMnOp"] + ) + + // QazwsxedcrfvtgbyhnujmikolpASDFGHJKLQWERTYUIOPZXCVBNM1234567abcdefghij + OR ( + b.OpQrStUvW <= e1.OpQrStUvW + AND e1.OpQrStUvW >= "QrStUvWx" + AND b.AbCdEfGhIjKlMn in ["YzXwVuTs","LmNoPqRs"] + // AND b.LmNo_PqRsTuV = e1.LmNo_PqRsTuV + ) + + // 0123456789abcdefghijKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVW + OR ( + b.OpQrStUvW = e1.OpQrStUvW + AND e1.OpQrStUvW >= "StUvWxYz" + AND b.AbCdEfGhIjKlMn > "QwErTyUi" + // AND b.LmNo_PqRsTuV = e1.LmNo_PqRsTuV + ) +) + +RETURN a, b, d, e1, zYxWvUtSrqP`; + const expected = `MATCH (a:AbCdEf {XyZ012345: "ABCDEFGH"}) + +MATCH (a)-[b:ZxCvBnMq]->(d:GhIjKlM)<-[e1:ZxCvBnMq]-(zYxWvUtSrqP:AbCdEf) +WHERE ( + // abcdefghijklmnopqrstuvwxy + (b.AbCdEfGhIjKlMn <= "1A2b3C4d") + + // Qwertyuiopasdfghjklzxcvbnm1234567890AB + OR (e1.OpQrStUvW >= "Z9y8X7w6" AND + b.AbCdEfGhIjKlMn IN ["aBcDeFgH", "IjKlMnOp"]) + + // QazwsxedcrfvtgbyhnujmikolpASDFGHJKLQWERTYUIOPZXCVBNM1234567abcdefghij + OR (b.OpQrStUvW <= e1.OpQrStUvW AND e1.OpQrStUvW >= "QrStUvWx" AND + b.AbCdEfGhIjKlMn IN ["YzXwVuTs", "LmNoPqRs"]) + // AND b.LmNo_PqRsTuV = e1.LmNo_PqRsTuV + + // 0123456789abcdefghijKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVW + OR (b.OpQrStUvW = e1.OpQrStUvW AND e1.OpQrStUvW >= "StUvWxYz" AND + b.AbCdEfGhIjKlMn > "QwErTyUi")) +// AND b.LmNo_PqRsTuV = e1.LmNo_PqRsTuV +RETURN a, b, d, e1, zYxWvUtSrqP`; + verifyFormatting(query, expected); + }); + + test('should preserve newlines also after a comment', () => { + const query = ` +MATCH (n) +// Comment, please preserve the line below + +RETURN n`.trimStart(); + const expected = query; + verifyFormatting(query, expected); + }); + + test('should keep the logical delimiter the user has made intact', () => { + const query = ` +MATCH (n) + +// === THIS IS THE START OF THE RETURN STATEMENT === + +RETURN n`.trimStart(); + const expected = query; + verifyFormatting(query, expected); + }); + + test('should allow an explicit newline between MATCH and WHERE', () => { + const query = ` +MATCH (n) + +WHERE n.prop = "String" +RETURN n`.trimStart(); + const expected = query; + verifyFormatting(query, expected); + }); + + test('should allow an explicit newline before LIMIT', () => { + const query = ` +MATCH (n) +RETURN n + +LIMIT 10`.trimStart(); + const expected = query; + verifyFormatting(query, expected); + }); + + test('WHERE and LIMIT with comments in between', () => { + const query = ` +MATCH (n) +// Comment before WHERE + +WHERE n.prop = "String" +RETURN n +// Comment before LIMIT + +LIMIT 10`.trimStart(); + const expected = query; + verifyFormatting(query, expected); + }); +});