From af39db9543c9525b448d167f1e47aaac37f06bf2 Mon Sep 17 00:00:00 2001 From: Johannes Spangenberg Date: Sun, 14 Jul 2024 11:56:00 +0200 Subject: [PATCH] Detect Indentation (Prototype #1) --- .../nixos/idea/util/NixStringIndentation.java | 149 ++++++++++++++++++ .../org/nixos/idea/util/NixStringUtil.java | 1 - .../idea/util/NixStringIndentationTest.java | 79 ++++++++++ 3 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/nixos/idea/util/NixStringIndentation.java create mode 100644 src/test/java/org/nixos/idea/util/NixStringIndentationTest.java diff --git a/src/main/java/org/nixos/idea/util/NixStringIndentation.java b/src/main/java/org/nixos/idea/util/NixStringIndentation.java new file mode 100644 index 00000000..b7024740 --- /dev/null +++ b/src/main/java/org/nixos/idea/util/NixStringIndentation.java @@ -0,0 +1,149 @@ +package org.nixos.idea.util; + +import com.intellij.lang.ASTNode; +import com.intellij.psi.tree.IElementType; +import org.jetbrains.annotations.NotNull; +import org.nixos.idea.psi.NixAntiquotation; +import org.nixos.idea.psi.NixIndString; +import org.nixos.idea.psi.NixStdString; +import org.nixos.idea.psi.NixString; +import org.nixos.idea.psi.NixStringPart; +import org.nixos.idea.psi.NixStringText; +import org.nixos.idea.psi.NixTypes; + +/** + * Represents the common indentation in indented strings. + */ +public final class NixStringIndentation { + + /** + * Instance which represents “no indentation”, and is returned for non-indented strings. + */ + private static final NixStringIndentation NONE = new NixStringIndentation(0, 0); + + /** + * The maximal amount of spaces removed by {@link #trim(CharSequence, boolean)} from each line. + */ + private final int myTrimIndentation; + /** + * The amount of spaces prepended by {@link #indent(CharSequence, boolean)} to each line. + */ + private final int myInsertIndentation; + + private NixStringIndentation(int trimIndentation, int insertIndentation) { + myTrimIndentation = trimIndentation; + myInsertIndentation = insertIndentation; + } + + /** + * Detects the common indentation within the given string. + * The returned indentation needs to be removed during decoding of the string, + * and added during encoding of the string. + * + * @param string the string from which to get the indentation + * @return the detected indentation + */ + public static @NotNull NixStringIndentation detect(@NotNull NixString string) { + if (string instanceof NixStdString) { + return NONE; + } else if (string instanceof NixIndString) { + enum State {PASS_INDENT, SEARCH_LINE_FEED} + State state = State.PASS_INDENT; + int commonIndentation = Integer.MAX_VALUE; + int currentIndentation = 0; + for (NixStringPart part : string.getStringParts()) { + assert part instanceof NixStringText || part instanceof NixAntiquotation : part.getClass(); + if (part instanceof NixStringText textNode) { + for (ASTNode token = textNode.getNode().getFirstChildNode(); token != null; token = token.getTreeNext()) { + IElementType type = token.getElementType(); + assert type == NixTypes.IND_STR || type == NixTypes.IND_STR_ESCAPE : type; + if (type == NixTypes.IND_STR) { + CharSequence text = token.getChars(); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '\n') { + currentIndentation = 0; + state = State.PASS_INDENT; + } else if (state == State.PASS_INDENT && c == ' ') { + currentIndentation += 1; + } else if (state == State.PASS_INDENT) { + commonIndentation = Math.min(commonIndentation, currentIndentation); + state = State.SEARCH_LINE_FEED; + } + } + } else if (state == State.PASS_INDENT) { + commonIndentation = Math.min(commonIndentation, currentIndentation); + state = State.SEARCH_LINE_FEED; + } + } + } else if (state == State.PASS_INDENT) { + commonIndentation = Math.min(commonIndentation, currentIndentation); + state = State.SEARCH_LINE_FEED; + } + } + if (commonIndentation == Integer.MAX_VALUE) { + int suggestedIndentation = 8; // TODO + return new NixStringIndentation(Integer.MAX_VALUE, suggestedIndentation); + } else { + return new NixStringIndentation(commonIndentation, commonIndentation); + } + } else { + throw new IllegalStateException("Unexpected subclass of NixString: " + string.getClass()); + } + } + + /** + * Remove indentation from given string. + * + * @param text the indented text + * @param firstLine whether the first line of the given text is indented + * @return the given text with the indentation removed + */ + public @NotNull String trim(@NotNull CharSequence text, boolean firstLine) { + StringBuilder result = new StringBuilder(); + int indentation = firstLine ? 0 : myTrimIndentation; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == ' ' && indentation++ < myTrimIndentation) { + continue; + } + indentation = myTrimIndentation; + result.append(c); + } + return result.toString(); + } + + /** + * Indent the given text. + * + * @param text the text which shall be indented + * @param firstLine whether the first line shall be indented + * @return indented version of the given string + */ + public @NotNull String indent(@NotNull CharSequence text, boolean firstLine) { + StringBuilder result = new StringBuilder(); + String indentation = " ".repeat(myInsertIndentation); + boolean startOfLine = firstLine; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '\n') { + startOfLine = true; + } else if (startOfLine) { + result.append(indentation); + startOfLine = false; + } + result.append(c); + } + return result.toString(); + } + + @Override + public String toString() { + if (myTrimIndentation == myInsertIndentation) { + return String.format("detected_indentation(%d)", myInsertIndentation); + } else { + assert myTrimIndentation == Integer.MAX_VALUE : myTrimIndentation; + return String.format("suggested_indentation(%d)", myInsertIndentation); + } + } +} diff --git a/src/main/java/org/nixos/idea/util/NixStringUtil.java b/src/main/java/org/nixos/idea/util/NixStringUtil.java index 33fe05f3..30a454d5 100644 --- a/src/main/java/org/nixos/idea/util/NixStringUtil.java +++ b/src/main/java/org/nixos/idea/util/NixStringUtil.java @@ -4,7 +4,6 @@ import com.intellij.psi.tree.IElementType; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; -import org.nixos.idea.psi.NixStringPart; import org.nixos.idea.psi.NixStringText; import org.nixos.idea.psi.NixTypes; diff --git a/src/test/java/org/nixos/idea/util/NixStringIndentationTest.java b/src/test/java/org/nixos/idea/util/NixStringIndentationTest.java new file mode 100644 index 00000000..cbbf8061 --- /dev/null +++ b/src/test/java/org/nixos/idea/util/NixStringIndentationTest.java @@ -0,0 +1,79 @@ +package org.nixos.idea.util; + +import com.intellij.openapi.project.Project; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.nixos.idea._testutil.WithIdeaPlatform; +import org.nixos.idea.psi.NixElementFactory; +import org.nixos.idea.psi.NixString; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@WithIdeaPlatform.OnEdt +final class NixStringIndentationTest { + + private final Project myProject; + + NixStringIndentationTest(Project project) { + myProject = project; + } + + @ParameterizedTest(name = "[{index}] {0} -> {1}") + @CsvSource(quoteCharacter = '|', textBlock = """ + # Non-indented strings always return the empty string + |""| , detected_indentation(0) + |" a"| , detected_indentation(0) + |" a\n b"| , detected_indentation(0) + # When there are only spaces, we don't know the correct indentation + |''''| , suggested_indentation(0) + |'' ''| , suggested_indentation(0) + |''\n \n ''| , suggested_indentation(0) + # The smallest indentation counts + |''\n a\n b''| , detected_indentation(1) + |''\n a\n b''| , detected_indentation(1) + |''\n a\n b''| , detected_indentation(2) + |''\n a\n ${b}''| , detected_indentation(1) + |''\n a\n ''\\b''| , detected_indentation(1) + # First line counts + |''a\n b''| , detected_indentation(0) + |''${a}\n b''| , detected_indentation(0) + |''''\\a\n b''| , detected_indentation(0) + # But only the first token in a line counts + |'' a${b}''| , detected_indentation(2) + |'' a''\\b''| , detected_indentation(2) + |'' ${a}b''| , detected_indentation(2) + |'' ${a}${b}''| , detected_indentation(2) + |'' ${a}''\\b''| , detected_indentation(2) + |'' ''\\ab''| , detected_indentation(2) + |'' ''\\a${b}''| , detected_indentation(2) + |'' ''\\a''\\b''| , detected_indentation(2) + # Tab and CR are treated as normal characters, not as spaces + # See NixOS/nix#2911 and NixOS/nix#3759 + |''\t''| , detected_indentation(0) + |''\n \t''| , detected_indentation(2) + |''\r\n''| , detected_indentation(0) + |''\n \r\n''| , detected_indentation(2) + # Indentation within interpolations is ignored + |'' ${\n"a"}''| , detected_indentation(2) + |'' ${\n''a''}''| , detected_indentation(2) + """) + void detect(String code, String expectedResult) { + NixString string = NixElementFactory.createString(myProject, code); + assertEquals(expectedResult, NixStringIndentation.detect(string).toString()); + } + + @Test + void indent() { + } + + @Test + void indent_ignores_empty_lines() { + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void indent_first_line(boolean indentFirstLine) { + } +}