diff --git a/src/main/java/org/nixos/idea/lang/NixStatementUpDownMover.java b/src/main/java/org/nixos/idea/lang/NixStatementUpDownMover.java index 181bcba..d85f8f7 100644 --- a/src/main/java/org/nixos/idea/lang/NixStatementUpDownMover.java +++ b/src/main/java/org/nixos/idea/lang/NixStatementUpDownMover.java @@ -1,24 +1,32 @@ package org.nixos.idea.lang; +import com.google.common.collect.Iterables; import com.intellij.codeInsight.editorActions.moveUpDown.LineMover; import com.intellij.codeInsight.editorActions.moveUpDown.LineRange; -import com.intellij.lang.ASTNode; import com.intellij.openapi.editor.Document; import com.intellij.openapi.editor.Editor; -import com.intellij.openapi.util.Pair; +import com.intellij.openapi.editor.LogicalPosition; import com.intellij.psi.PsiComment; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.psi.PsiWhiteSpace; import com.intellij.psi.tree.TokenSet; import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.psi.util.PsiUtilBase; import com.intellij.util.text.CharArrayUtil; +import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.nixos.idea.file.NixFile; +import org.nixos.idea.psi.NixExprIf; import org.nixos.idea.psi.NixStatementLike; import org.nixos.idea.psi.NixTypes; +import java.util.ArrayDeque; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; +import java.util.List; import java.util.Set; /** @@ -27,10 +35,14 @@ * @see Move Statement Documentation */ public final class NixStatementUpDownMover extends LineMover { + + private static final TokenSet STATEMENT_END_TOKENS = TokenSet.create(NixTypes.SEMI, NixTypes.IN, NixTypes.COLON); + private static final TokenSet INSERTION_POINT_TOKENS = TokenSet.orSet(STATEMENT_END_TOKENS, TokenSet.create(NixTypes.THEN, NixTypes.ELSE)); + @Override public boolean checkAvailable(@NotNull Editor editor, @NotNull PsiFile file, @NotNull MoveInfo info, boolean down) { // Must return true if this instance shall handle the move. - // If the method returns true, it must set the MoveInfo.toMove and MoveInfo.toMove2. + // If the method returns true, it must set MoveInfo.toMove and MoveInfo.toMove2. // The two line ranges set on MoveInfo will be swapped after this method returns. if (!(file instanceof NixFile)) { return false; @@ -39,59 +51,68 @@ public boolean checkAvailable(@NotNull Editor editor, @NotNull PsiFile file, @No return false; } - Pair range = getElementRange(editor, file, info.toMove); - if (range == null) { - return false; - } + Document document = editor.getDocument(); + assert document == file.getFileDocument(); + int startOffset = editor.logicalPositionToOffset(new LogicalPosition(info.toMove.startLine, 0)); + int endOffset = editor.logicalPositionToOffset(new LogicalPosition(info.toMove.endLine, 0)); - PsiElement first = PsiTreeUtil.getDeepestFirst(range.getFirst()); - PsiElement last = PsiTreeUtil.getDeepestLast(range.getSecond()); - PsiElement commonParent = findCommonParentStrict(first, last); - if (commonParent == null) { - return false; - } + MoveRequest request = new MoveRequest(editor, file, document, info, startOffset, endOffset, down); + tryMoveRecursive(request, file); - StatementLikeHandler statementHandler = new StatementLikeHandler(); - for (PsiElement parent = first.getParent(); parent != commonParent; parent = first.getParent()) { - statementHandler.firstPart(first, parent); - first = parent; - } - for (PsiElement parent = last.getParent(); parent != commonParent; parent = last.getParent()) { - statementHandler.lastPart(last, parent); - last = parent; - } + return true; + } - while (commonParent != null) { - if (tryMoveSubElements(editor, commonParent, first, last, info, down)) { - return true; + private static void tryMoveRecursive(@NotNull MoveRequest request, @NotNull PsiFile file) { + Deque elements = new ArrayDeque<>(); + + PsiElement element = file; + PsiElement lineStart = file; + PsiElement lineEnd = file; + int offset = 0; + while (true) { + Selection selection = Selection.resolve(request, element, offset); + if (selection == null) { + // We have only selected empty lines. Use default behavior of LineMover. + return; } - if (statementHandler.tryMove(editor, commonParent, first, last, info, down)) { - return true; + + ElementInfo elementInfo = new ElementInfo(element, lineStart, lineEnd, selection, offset); + elements.addFirst(elementInfo); + + element = asCodeElementForward(selection.firstElement()); + if (element == null || element != asCodeElementBackward(selection.lastElement()) || element.getFirstChild() == null) { + break; } + offset = offset + element.getStartOffsetInParent(); + lineStart = selection.firstOfLine(elementInfo); + lineEnd = selection.lastOfLine(elementInfo); + } - first = last = commonParent; - commonParent = commonParent.getParent(); + Collection unmodifiableView = Collections.unmodifiableCollection(elements); + while (!elements.isEmpty()) { + ElementInfo elementInfo = elements.removeFirst(); + if (tryMoveSubElements(request, elementInfo)) { + return; + } + if (tryMoveStatements(request, elementInfo, unmodifiableView)) { + return; + } } // We don't fall back to the default behavior. // If we don't know how to move the lines in a meaningful way, we will not move them. - info.prohibitMove(); - return true; + request.info().prohibitMove(); } /** * Try moving elements from list-like elements as reported by {@link NixMoveElementLeftRightHandler}. */ - private boolean tryMoveSubElements( - @NotNull Editor editor, - @NotNull PsiElement commonParent, - @NotNull PsiElement first, - @NotNull PsiElement last, - @NotNull MoveInfo info, - boolean down - ) { - first = expandLine(first, true); - last = expandLine(last, false); + private static boolean tryMoveSubElements(@NotNull MoveRequest request, @NotNull ElementInfo elementInfo) { + MoveInfo info = request.info(); + PsiElement commonParent = elementInfo.element(); + PsiElement first = expandLine(elementInfo.selection().firstElement(), true); + PsiElement last = expandLine(elementInfo.selection().lastElement(), false); + if (first == null || last == null) { return false; } @@ -108,11 +129,11 @@ private boolean tryMoveSubElements( } } - if (tryMoveOverBlankLine(editor, info, first, last, down)) { + if (tryMoveOverBlankLine(request, first, last)) { return true; } - PsiElement next = down ? afterLast : PsiTreeUtil.skipWhitespacesBackward(first); + PsiElement next = request.down() ? afterLast : PsiTreeUtil.skipWhitespacesBackward(first); if (next == null) { info.prohibitMove(); return true; @@ -132,230 +153,232 @@ private boolean tryMoveSubElements( } } - return tryMoveOver(editor, info, first, last, nextFirst, nextLast); + return tryMoveOver(request, first, last, nextFirst, nextLast); } /** - * Handler for moving expressions up or down in the tree. + * Try moving expressions up or down in the tree. * For example moving an assertion into the body of a let-expression. */ - private static final class StatementLikeHandler { - // I don't really like the implementation of this class. - // It is full of implicit assumptions about the syntax of the Nix expression language. - // Anyway, I did not have a good idea how to make it better and at least we have decent test coverage. - - private static final TokenSet STATEMENT_END_TOKENS = TokenSet.create(NixTypes.SEMI, NixTypes.IN, NixTypes.COLON); - private static final TokenSet INSERTION_POINT_TOKENS = TokenSet.orSet(STATEMENT_END_TOKENS, TokenSet.create(NixTypes.THEN, NixTypes.ELSE)); - - private @Nullable PsiElement myFirst; - private @Nullable PsiElement myLast; - private @Nullable PsiElement myNextInsertionPoint; - - private record JumpRange(@NotNull PsiElement lastOfRange, @Nullable PsiElement insertionPoint) {} + private static boolean tryMoveStatements( + @NotNull MoveRequest request, + @NotNull ElementInfo elementInfo, + @NotNull Collection parents + ) { + BlockOfStatements block = resolveSelectedStatement(request, elementInfo, parents); + if (block == null) { + return false; + } - private void firstPart(@NotNull PsiElement first, @NotNull PsiElement parent) { - if (!(parent instanceof NixStatementLike)) { - myFirst = null; - } else if (myFirst == null) { - myFirst = findStatementStartLine(parent); - } + if (tryMoveOverBlankLine(request, block.first(), block.last())) { + return true; } - private void lastPart(@NotNull PsiElement last, @NotNull PsiElement parent) { - if (!(parent instanceof NixStatementLike)) { - myLast = null; - myNextInsertionPoint = null; - } else { - JumpRange end = findSelectionEnd(parent, last); - if (end != null) { - myLast = end.lastOfRange(); - myNextInsertionPoint = end.insertionPoint(); + if (request.down()) { + PsiElement insertionPoint = null; + if (block.next() != null) { + insertionPoint = findNextInsertionPoint(block.next(), null); + } + if (insertionPoint == null) { + insertionPoint = findNextInsertionPoint(block.parents(), block.lastInParent()); + } + if (insertionPoint != null) { + // TODO Do I need `next`? + PsiElement next = nextLeaf(block.last()); + if (next == null) { + assert false : "Should be unreachable"; } else { - myLast = null; - myNextInsertionPoint = null; + return tryMoveOver(request, block.first(), block.last(), next, insertionPoint); } } - } - - private boolean tryMove( - @NotNull Editor editor, - @NotNull PsiElement commonParent, - @NotNull PsiElement first, - @NotNull PsiElement last, - @NotNull MoveInfo info, - boolean down - ) { - if (!(commonParent instanceof NixStatementLike)) { - if (myFirst != null && myLast != null) { - // We do not want to move statements much further up in the tree - // if the user has selected statements inside the current subtree. - info.prohibitMove(); - return true; + } else { + PsiElement insertionPoint = findPreviousInsertionPoint(block.parents(), block.firstInParent()); + if (insertionPoint != null) { + PsiElement previous = prevLeaf(block.first()); + if (previous == null) { + assert false : "Should be unreachable"; } else { - myFirst = null; - myLast = null; - myNextInsertionPoint = null; - return false; + return tryMoveOver(request, block.first(), block.last(), insertionPoint, previous); } } + } - firstPart(first, commonParent); - if (first != last || myLast == null) { - lastPart(last, commonParent); - } + // We do not want to move statements much further up in the tree + // if the user has selected statements inside the current subtree. + request.info().prohibitMove(); + return true; + } - if (myFirst == null || myLast == null) { - return false; - } - if (tryMoveOverBlankLine(editor, info, myFirst, myLast, down)) { - return true; - } + private static @Nullable BlockOfStatements resolveSelectedStatement( + @NotNull MoveRequest request, + @NotNull ElementInfo elementInfo, + @NotNull Collection parents + ) { + Selection selection = elementInfo.selection(); + + PsiElement firstSelected = expandLine(selection.firstElement(), true); + PsiElement lastSelected = expandLine(selection.lastElement(), false); + PsiElement next = asCodeElementForward(firstSelected); + if (lastSelected != null && next != null && next.getStartOffsetInParent() > lastSelected.getStartOffsetInParent()) { + return new BlockOfStatements( + firstSelected, lastSelected, next, + Iterables.concat(List.of(elementInfo), parents), firstSelected, lastSelected + ); + } - if (down) { - PsiElement insertionPoint = myNextInsertionPoint; - if (insertionPoint == null && first == last) { - insertionPoint = findNextInsertionPoint(commonParent, last); - } - if (insertionPoint != null && insertionPoint != myLast) { - PsiElement next = nextLeaf(myLast); - if (next == null) { - assert false : "Should be unreachable"; - } else { - return tryMoveOver(editor, info, myFirst, myLast, next, insertionPoint); - } - } - } else if (first == last) { - PsiElement insertionPoint = findPreviousInsertionPoint(commonParent, first); + PsiElement lineStart = elementInfo.lineStart(); + if (!(elementInfo.element() instanceof NixStatementLike element) || lineStart == null) { + return null; + } + + PsiElement statementTerminator = findStatementTerminator(element); + next = PsiTreeUtil.skipWhitespacesAndCommentsForward(statementTerminator); + if (next != null && next == firstSelected) { + return null; + } + + PsiElement selectionEnd = findSelectionEnd(request, element, elementInfo.offset(), elementInfo.selection()); + if (selectionEnd == null) { + return null; + } + + return new BlockOfStatements( + lineStart, selectionEnd, PsiTreeUtil.skipWhitespacesAndCommentsForward(selectionEnd), + parents, element, element + ); + } + + private static @Nullable PsiElement findSelectionEnd(@NotNull MoveRequest request, @NotNull PsiElement parent, int offset, @Nullable Selection selection) { + if (!(parent instanceof NixStatementLike stmt)) { + return null; + } + + PsiElement stmtEnd = findStatementTerminator(stmt); + if (stmtEnd == null) { + return null; + } + + PsiElement next = PsiTreeUtil.skipWhitespacesAndCommentsForward(stmtEnd); + PsiElement endOfLine = expandLine(stmtEnd, false); + if (next == null) { + return null; + } + + if (selection != null && endOfLine != null && selection.lastElement().getStartOffsetInParent() > endOfLine.getStartOffsetInParent()) { + endOfLine = expandLine(selection.lastElement(), false); + } + + if (endOfLine != null && next.getStartOffsetInParent() > endOfLine.getStartOffsetInParent()) { + return endOfLine; + } else if (selection != null && next.getStartOffsetInParent() <= selection.lastElement().getStartOffsetInParent()) { + int childOffset = offset + next.getStartOffsetInParent(); + return findSelectionEnd(request, next, childOffset, Selection.resolve(request, next, childOffset)); + } else { + return findSelectionEnd(request, next, offset + next.getStartOffsetInParent(), null); + } + } + + private static @Nullable PsiElement findPreviousInsertionPoint(@NotNull Iterable parents, @NotNull PsiElement before) { + for (ElementInfo parent : parents) { + if (parent.element() instanceof NixExprIf let) { + PsiElement insertionPoint = findPreviousInsertionPoint(let, before); if (insertionPoint != null) { - PsiElement previous = prevLeaf(myFirst); - if (previous == null) { - assert false : "Should be unreachable"; - } else { - return tryMoveOver(editor, info, myFirst, myLast, insertionPoint, previous); - } + return insertionPoint; + } + if (parent.lineStart() != null) { + return parent.lineStart(); } - PsiElement startOfLine = findStatementStartLine(commonParent); - if (startOfLine != null && startOfLine != myFirst) { - PsiElement previous = prevLeaf(myFirst); - if (previous == null) { - assert false : "Should be unreachable"; - } else { - return tryMoveOver(editor, info, myFirst, myLast, startOfLine, previous); - } + } else if (parent.element() instanceof NixStatementLike) { + if (parent.lineStart() != null) { + return parent.lineStart(); } + } else { + // We cannot move our statement out of arbitrary expressions. + return null; } - return false; + before = parent.element(); } + return null; + } - private @Nullable PsiElement findStatementStartLine(@NotNull PsiElement statement) { - if (!(statement instanceof NixStatementLike)) { - return null; - } + private static @Nullable PsiElement findPreviousInsertionPoint(@NotNull PsiElement parent, @Nullable PsiElement before) { + if (!(parent instanceof NixStatementLike)) { + return null; + } - PsiElement current = statement; - while (true) { - PsiElement previous = current.getPrevSibling(); - if (previous == null) { - PsiElement parent = current.getParent(); - if (parent == null || parent instanceof PsiFile) { - return current; - } else { - current = parent; - } - } else if (previous instanceof PsiWhiteSpace) { - if (previous.textContains('\n')) { - return current; - } else { - current = previous; - } - } else if (previous instanceof PsiComment) { - current = previous; - } else { + PsiElement start = before == null ? parent.getLastChild() : + PsiTreeUtil.skipWhitespacesBackward(PsiTreeUtil.skipWhitespacesAndCommentsBackward(before)); + for (PsiElement cur = start; cur != null; cur = PsiTreeUtil.skipWhitespacesBackward(cur)) { + if (INSERTION_POINT_TOKENS.contains(cur.getNode().getElementType())) { + PsiElement childExpression = PsiTreeUtil.skipWhitespacesAndCommentsForward(cur); + if (childExpression == null /* || childExpression == myFirst */) { return null; } - } - } - private @Nullable JumpRange findSelectionEnd(@NotNull PsiElement parent, @Nullable PsiElement lastSelected) { - if (!(parent instanceof NixStatementLike)) { - return null; - } + PsiElement insertionPoint = findPreviousInsertionPoint(childExpression, null); + if (insertionPoint != null) { + return insertionPoint; + } - ASTNode stmtEndToken = parent.getNode().findChildByType(STATEMENT_END_TOKENS); - if (stmtEndToken == null) { - return null; + PsiElement startOfLine = expandLine(childExpression, true); + if (startOfLine != null && startOfLine.getStartOffsetInParent() > cur.getStartOffsetInParent()) { + return startOfLine; + } } + } + return null; + } - PsiElement stmtEnd = stmtEndToken.getPsi(); - PsiElement next = PsiTreeUtil.skipWhitespacesAndCommentsForward(stmtEnd); - PsiElement endOfLine = expandLine(stmtEnd, false); - if (next == null) { + private static @Nullable PsiElement findNextInsertionPoint(@NotNull Iterable parents, @NotNull PsiElement after) { + for (ElementInfo parent : parents) { + if (parent.element() instanceof NixExprIf let) { + PsiElement insertionPoint = findNextInsertionPoint(let, after); + if (insertionPoint != null) { + return insertionPoint; + } + } else if (!(parent.element() instanceof NixStatementLike)) { + // We cannot move our statement out of arbitrary expressions. return null; } - if (lastSelected != null && endOfLine != null && lastSelected.getStartOffsetInParent() > endOfLine.getStartOffsetInParent()) { - endOfLine = expandLine(lastSelected, false); - } + after = parent.element(); + } + return null; + } - if (endOfLine != null && next.getStartOffsetInParent() > endOfLine.getStartOffsetInParent()) { - return new JumpRange(endOfLine, findNextInsertionPoint(next, null)); - } else if (next == lastSelected) { - // Optimization: `lastSelected` has already been processed - return myLast == null ? null : new JumpRange(myLast, myNextInsertionPoint); - } else { - return findSelectionEnd(next, null); - } + private static @Nullable PsiElement findNextInsertionPoint(@NotNull PsiElement parent, @Nullable PsiElement after) { + if (!(parent instanceof NixStatementLike)) { + return null; } - private @Nullable PsiElement findPreviousInsertionPoint(@NotNull PsiElement parent, @Nullable PsiElement before) { - if (!(parent instanceof NixStatementLike)) { - return null; - } + PsiElement first = after == null ? parent.getFirstChild() : PsiTreeUtil.skipWhitespacesForward(after); + for (PsiElement cur = first; cur != null; cur = PsiTreeUtil.skipWhitespacesForward(cur)) { + if (INSERTION_POINT_TOKENS.contains(cur.getNode().getElementType())) { + PsiElement next = PsiTreeUtil.skipWhitespacesAndCommentsForward(cur); + PsiElement endOfLine = expandLine(cur, false); + if (next == null || endOfLine == null) { + return null; + } - PsiElement start = before == null ? parent.getLastChild() : - PsiTreeUtil.skipWhitespacesBackward(PsiTreeUtil.skipWhitespacesAndCommentsBackward(before)); - for (PsiElement cur = start; cur != null; cur = PsiTreeUtil.skipWhitespacesBackward(cur)) { - if (INSERTION_POINT_TOKENS.contains(cur.getNode().getElementType())) { - PsiElement childExpression = PsiTreeUtil.skipWhitespacesAndCommentsForward(cur); - if (childExpression == null /* || childExpression == myFirst */) { - return null; - } - - PsiElement insertionPoint = findPreviousInsertionPoint(childExpression, null); - if (insertionPoint != null) { - return insertionPoint; - } - - PsiElement startOfLine = expandLine(childExpression, true); - if (startOfLine != null && startOfLine.getStartOffsetInParent() > cur.getStartOffsetInParent()) { - return startOfLine; - } + if (next.getStartOffsetInParent() > endOfLine.getStartOffsetInParent()) { + return endOfLine; + } else { + return findNextInsertionPoint(next, null); } } - return null; } + return null; + } - private static @Nullable PsiElement findNextInsertionPoint(@NotNull PsiElement parent, @Nullable PsiElement after) { - if (!(parent instanceof NixStatementLike)) { + private static @Nullable PsiElement findStatementTerminator(@NotNull NixStatementLike element) { + PsiElement lastElement = element.getLastChild(); + if (STATEMENT_END_TOKENS.contains(PsiUtilBase.getElementType(lastElement))) { + return lastElement; + } else { + PsiElement statementEnd = PsiTreeUtil.skipWhitespacesAndCommentsBackward(lastElement); + if (!STATEMENT_END_TOKENS.contains(PsiUtilBase.getElementType(statementEnd))) { return null; } - - PsiElement first = after == null ? parent.getFirstChild() : PsiTreeUtil.skipWhitespacesForward(after); - for (PsiElement cur = first; cur != null; cur = PsiTreeUtil.skipWhitespacesForward(cur)) { - if (INSERTION_POINT_TOKENS.contains(cur.getNode().getElementType())) { - PsiElement next = PsiTreeUtil.skipWhitespacesAndCommentsForward(cur); - PsiElement endOfLine = expandLine(cur, false); - if (next == null || endOfLine == null) { - return null; - } - - if (next.getStartOffsetInParent() > endOfLine.getStartOffsetInParent()) { - return endOfLine; - } else { - return findNextInsertionPoint(next, null); - } - } - } - return null; + return statementEnd; } } @@ -365,16 +388,15 @@ private boolean tryMove( * @return {@code true} if the line to cross was empty and {@code info} has been adjusted accordingly. */ private static boolean tryMoveOverBlankLine( - @NotNull Editor editor, - @NotNull MoveInfo info, + @NotNull MoveRequest request, @NotNull PsiElement first, - @NotNull PsiElement last, - boolean down + @NotNull PsiElement last ) { - Document document = editor.getDocument(); + MoveInfo info = request.info(); + Document document = request.document(); LineRange range = createRange(document, info.toMove, first, last); - int target = down ? range.endLine : range.startLine - 1; + int target = request.down() ? range.endLine : range.startLine - 1; if (target < 0 || target > document.getLineCount()) { return false; } @@ -400,14 +422,14 @@ private static boolean tryMoveOverBlankLine( * @return {@code true} */ private static boolean tryMoveOver( - @NotNull Editor editor, - @NotNull MoveInfo info, + @NotNull MoveRequest request, @NotNull PsiElement move1First, @NotNull PsiElement move1Last, @NotNull PsiElement move2First, @NotNull PsiElement move2Last ) { - Document document = editor.getDocument(); + MoveInfo info = request.info(); + Document document = request.document(); info.toMove = createRange(document, info.toMove, move1First, move1Last); info.toMove2 = createRange(document, null, move2First, move2Last); assert Integer.max(info.toMove.startLine, info.toMove2.endLine) > Integer.min(info.toMove.endLine, info.toMove2.startLine) @@ -442,19 +464,11 @@ private static boolean tryMoveOver( return new LineRange(firstLine, lastLine + 1); } - private static @Nullable PsiElement findCommonParentStrict(@NotNull PsiElement element1, @NotNull PsiElement element2) { - PsiElement commonParent = PsiTreeUtil.findCommonParent(element1, element2); - if (commonParent == element1 || commonParent == element2) { - return commonParent.getParent(); - } else { - return commonParent; - } - } - private static @Nullable PsiElement expandLine(@NotNull PsiElement element, boolean left) { while (true) { PsiElement next = left ? element.getPrevSibling() : element.getNextSibling(); if (next == null) { + // TODO Can we simplify this again? PsiElement nextLeaf = left ? PsiTreeUtil.prevLeaf(element) : PsiTreeUtil.nextLeaf(element); if (nextLeaf == null || nextLeaf instanceof PsiWhiteSpace && nextLeaf.textContains('\n')) { return element; @@ -485,4 +499,128 @@ private static boolean tryMoveOver( } return previous; } + + @Contract("null -> null") + private static @Nullable PsiElement asCodeElementBackward(@Nullable PsiElement element) { + if (element instanceof PsiComment) { + return PsiTreeUtil.skipWhitespacesAndCommentsBackward(element); + } else { + return element; + } + } + + @Contract("null -> null") + private static @Nullable PsiElement asCodeElementForward(@Nullable PsiElement element) { + if (element instanceof PsiComment) { + return PsiTreeUtil.skipWhitespacesAndCommentsForward(element); + } else { + return element; + } + } + + private record MoveRequest( + @NotNull Editor editor, + @NotNull PsiFile file, + @NotNull Document document, + @NotNull MoveInfo info, + int startOffset, + int endOffset, + boolean down + ) {} + + private record ElementInfo( + @NotNull PsiElement element, + @Nullable PsiElement lineStart, + @Nullable PsiElement lineEnd, + @NotNull Selection selection, + int offset + ) {} + + private record BlockOfStatements( + @NotNull PsiElement first, + @NotNull PsiElement last, + @Nullable PsiElement next, + @NotNull Iterable parents, + @NotNull PsiElement firstInParent, + @NotNull PsiElement lastInParent + ) {} + + /** + * First and last direct child which is (partially) selected. + * + * @param firstElement First non-whitespace element which is at least partially selected. + * @param lastElement Last non-whitespace element which is at least partially. + */ + private record Selection( + @NotNull PsiElement firstElement, + @NotNull PsiElement lastElement + ) { + /** + * Resolves the selection within a specific parent element. + * Returns {@code null} if the selection contains only whitespaces. + * + * @param request The request containing the selected range. + * @param parent The parent element for which to resolve the selection. + * @param offset The start offset of {@code parent}. + * @return The first and last non-whitespace element which is at least partially selected, or {@code null}. + */ + private static @Nullable Selection resolve(@NotNull MoveRequest request, @NotNull PsiElement parent, int offset) { + assert request.endOffset() > offset || request.startOffset() < offset + parent.getTextLength() + : "no selection within parent"; + + PsiElement firstSelected = null; + PsiElement lastSelected = null; + + for (PsiElement cur = parent.getFirstChild(); cur != null; cur = cur.getNextSibling()) { + int curStartOffset = offset + cur.getStartOffsetInParent(); + int curEndOffset = curStartOffset + cur.getTextLength(); + if (curStartOffset >= request.endOffset()) { + break; + } + if (curEndOffset <= request.startOffset() || cur instanceof PsiWhiteSpace) { + continue; + } + if (firstSelected == null) { + firstSelected = cur; + } + lastSelected = cur; + } + + if (firstSelected == null /* implicit: || lastSelected == null */) { + return null; + } else { + return new Selection(firstSelected, lastSelected); + } + } + + /** + * First element of the line at the start of the selection. + * This is either the same element as {@link #firstElement()}, or a comment. + */ + private @Nullable PsiElement firstOfLine(@NotNull ElementInfo parent) { + PsiElement firstInLine = expandLine(firstElement, true); + PsiElement elementBeforeSelection = PsiTreeUtil.skipWhitespacesAndCommentsBackward(firstElement); + if (elementBeforeSelection != null && + (firstInLine == null || firstInLine.getStartOffsetInParent() <= elementBeforeSelection.getStartOffsetInParent())) { + return null; // There is another element in the same (extended) line + } else { + return firstInLine == null ? parent.lineStart() : firstInLine; + } + } + + /** + * Last element of the line at the end of the selection. + * This is either the same element as {@link #lastElement()}, or a comment. + */ + private @Nullable PsiElement lastOfLine(@NotNull ElementInfo parent) { + PsiElement lastInLine = expandLine(lastElement, false); + PsiElement elementBehindSelection = PsiTreeUtil.skipWhitespacesAndCommentsForward(lastElement); + if (elementBehindSelection != null && + (lastInLine == null || lastInLine.getStartOffsetInParent() >= elementBehindSelection.getStartOffsetInParent())) { + return null; // There is another element in the same (extended) line + } else { + return lastInLine == null ? parent.lineEnd() : lastInLine; + } + } + } } diff --git a/src/main/lang/Nix.bnf b/src/main/lang/Nix.bnf index 2056bd2..0dbb01e 100644 --- a/src/main/lang/Nix.bnf +++ b/src/main/lang/Nix.bnf @@ -96,7 +96,7 @@ private expr0 ::= | expr_op expr_assert ::= ASSERT expr SEMI expr { pin=1 implements=statement_like methods=[ assertion="/expr[0]" next="/expr[1]" ] } -expr_if ::= IF expr then else { pin=1 implements=statement_like } +expr_if ::= IF expr then else { pin=1 implements=statement_like methods=[ next="/expr[2]" ] } // TODO Remove statement_like? private then ::= THEN expr { pin=1 } private else ::= ELSE expr { pin=1 } expr_let ::= LET !LCURLY recover_let (bind recover_let)* IN expr { pin=2 implements=statement_like methods=[ next="expr" ] } @@ -105,7 +105,7 @@ private recover_let ::= { recoverWhile=let_recover } private let_recover ::= braces_recover !(ASSERT | SEMI | IF | THEN | ELSE | LET | IN | WITH) !bind ;{ methods("argument|formal|parameter")=[ identifier="parameter_name" ] } -expr_lambda ::= lambda_params !missing_semi COLON expr { pin=3 implements=statement_like } +expr_lambda ::= lambda_params !missing_semi COLON expr { pin=3 implements=statement_like methods=[ next="expr" ] } private lambda_params ::= argument [ !missing_semi AT formals ] | formals [ !missing_semi AT argument ] argument ::= parameter_name { implements=parameter } formals ::= LCURLY ( formal COMMA )* [ ( ELLIPSIS | formal ) ] recover_formals RCURLY { pin=1 } @@ -234,7 +234,7 @@ attr_path ::= attr ( DOT attr )* { methods=[ firstAttr="/attr[0]" ] } // Interface for identifiers. fake identifier ::= ID | OR_KW -fake statement_like ::= { extends="expr" } +fake statement_like ::= [expr] { extends="expr" methods=[ next="expr" ] } // The lexer uses curly braces to determine its state. To avoid inconsistencies // between the parser and lexer (i.e. the lexer sees a string where the parser diff --git a/src/test/java/org/nixos/idea/lang/NixStatementUpDownMoverTest.java b/src/test/java/org/nixos/idea/lang/NixStatementUpDownMoverTest.java index d1921b7..84bae55 100644 --- a/src/test/java/org/nixos/idea/lang/NixStatementUpDownMoverTest.java +++ b/src/test/java/org/nixos/idea/lang/NixStatementUpDownMoverTest.java @@ -1365,13 +1365,13 @@ void move_up_with_empty_line() { assert a; assert b; \s - """); + _"""); doMoveUp(); expect(""" assert b; \s assert a; - """); + _"""); } @Test @@ -1381,21 +1381,21 @@ void move_over_empty_line_with_empty_line() { assert a; \s assert b; - """); + _"""); doMoveDown(); expect(""" assert b; \s assert a; \s - """); + _"""); doMoveUp(); expect(""" \s assert a; \s assert b; - """); + _"""); } @Test