Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,15 @@ public class NixSyntaxHighlighter extends SyntaxHighlighterBase {
entry(NixTypes.URI, NixTextAttributes.URI),
// String literals
entry(NixTypes.STR, NixTextAttributes.STRING),
entry(NixTypes.STR_ESCAPE, NixTextAttributes.STRING_ESCAPE),
entry(NixTypes.STRING_CLOSE, NixTextAttributes.STRING),
entry(NixTypes.STRING_OPEN, NixTextAttributes.STRING),
entry(NixTypes.IND_STR, NixTextAttributes.STRING),
entry(NixTypes.IND_STR_LF, NixTextAttributes.STRING),
entry(NixTypes.IND_STR_INDENT, NixTextAttributes.STRING),
entry(NixTypes.IND_STR_ESCAPE, NixTextAttributes.STRING_ESCAPE),
entry(NixTypes.IND_STRING_CLOSE, NixTextAttributes.STRING),
entry(NixTypes.IND_STRING_OPEN, NixTextAttributes.STRING),
entry(NixTypes.STR_ESCAPE, NixTextAttributes.STRING_ESCAPE),
entry(NixTypes.IND_STR_ESCAPE, NixTextAttributes.STRING_ESCAPE),
// Other
entry(NixTypes.SCOMMENT, NixTextAttributes.LINE_COMMENT),
entry(NixTypes.MCOMMENT, NixTextAttributes.BLOCK_COMMENT),
Expand Down
8 changes: 8 additions & 0 deletions src/main/java/org/nixos/idea/psi/NixElementFactory.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ private NixElementFactory() {} // Cannot be instantiated
return createElement(project, NixString.class, "", code, "");
}

public static @NotNull NixStringText createStdStringText(@NotNull Project project, @NotNull String code) {
return createElement(project, NixStringText.class, "\"", code, "\"");
}

public static @NotNull NixStringText createIndStringText(@NotNull Project project, @NotNull String code) {
return createElement(project, NixStringText.class, "''\n", code, "''");
}

public static @NotNull NixAttr createAttr(@NotNull Project project, @NotNull String code) {
return createElement(project, NixAttr.class, "x.", code, "");
}
Expand Down
92 changes: 92 additions & 0 deletions src/main/java/org/nixos/idea/psi/NixInjectionPerformer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package org.nixos.idea.psi;

import com.intellij.lang.Language;
import com.intellij.lang.injection.MultiHostRegistrar;
import com.intellij.lang.injection.general.Injection;
import com.intellij.lang.injection.general.LanguageInjectionPerformer;
import com.intellij.openapi.fileTypes.LanguageFileType;
import com.intellij.psi.LiteralTextEscaper;
import com.intellij.psi.PsiElement;
import com.intellij.psi.util.PsiTreeUtil;
import org.jetbrains.annotations.NotNull;

import java.util.ArrayList;
import java.util.List;

public class NixInjectionPerformer implements LanguageInjectionPerformer {

@Override
public boolean isPrimary() {
return true;
}

@Override
public boolean performInjection(@NotNull MultiHostRegistrar registrar, @NotNull Injection injection, @NotNull PsiElement context) {

// TODO Support injection on quotes?
// It is currently not possible to press Alt+Enter on the quotes to enable language injections.
// TODO Add InjectedLanguageManager.FRANKENSTEIN_INJECTION when there are interpolations?
// This seems to be used by various languages to disable certain error checks as when part of the source code is unavailable.
// TODO JetBrains implementations of LanguageInjectionPerformer do more stuff,
// like calling InjectorUtils.registerSupport(...). Not sure whether this is relevant.
// TODO Adding new interpolations after enabling a language injection has strange effect.
// TODO What about LanguageInjectionSupport? https://plugins.jetbrains.com/docs/intellij/language-injection.html

NixString string = PsiTreeUtil.getParentOfType(context, NixString.class, false);
if (string == null) {
return false;
}
Language injectedLanguage = injection.getInjectedLanguage();
if (injectedLanguage == null) {
return false;
}

LanguageFileType injectedFileType = injectedLanguage.getAssociatedFileType();
String injectedFileExtension = injectedFileType == null ? null : injectedFileType.getDefaultExtension();

registrar.startInjecting(injectedLanguage, injectedFileExtension);
for (Place place : collectPlaces(string, injection.getPrefix(), injection.getSuffix())) {
LiteralTextEscaper<?> escaper = place.item().createLiteralTextEscaper();
registrar.addPlace(
place.prefix().toString(), place.suffix().toString(),
place.item(), escaper.getRelevantTextRange()
);
}
registrar.doneInjecting();
return true;
}

private record Place(@NotNull NixStringText item, @NotNull CharSequence prefix, @NotNull CharSequence suffix) {}

private static List<Place> collectPlaces(@NotNull NixString string, @NotNull String prefix, @NotNull String suffix) {
int interpolations = 0;
StringBuilder prevSuffix = null;
List<Place> result = new ArrayList<>();
for (NixStringPart stringPart : string.getStringParts()) {
// Note: The first and last part is always of type NixStringText, the list is never empty
if (stringPart instanceof NixStringText text) {
prevSuffix = new StringBuilder();
result.add(new Place(text, prefix, prevSuffix));
prefix = "";
} else {
assert stringPart instanceof NixAntiquotation : stringPart.getClass();
assert prevSuffix != null;
prevSuffix.append(interpolationPlaceholder(interpolations++));
}
}
assert prevSuffix != null;
prevSuffix.append(suffix);
return result;
}

/**
* Arbitrary code used in the guest language in place for string interpolations.
* This code is not visible for the user in the IDE as IDEA will visualize source code of the interpolations instead.
* This method returns a different string for each interpolation to prevent tooling of the guess language
* from assuming that all interpolations generate the same result.
*/
private static String interpolationPlaceholder(int index) {
// TODO What should I return here? Would an empty string work? What do other plugins use as placeholders?
return "(__interpolation" + index + "__)";
}
}
68 changes: 68 additions & 0 deletions src/main/java/org/nixos/idea/psi/NixStringLiteralEscaper.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package org.nixos.idea.psi

import com.intellij.openapi.util.TextRange
import com.intellij.psi.LiteralTextEscaper
import org.nixos.idea.psi.impl.AbstractNixString
import org.nixos.idea.util.NixStringUtil

class NixStringLiteralEscaper(host: AbstractNixString) : LiteralTextEscaper<NixStringText>(host) {

override fun isOneLine(): Boolean = false

@JojOatXGME JojOatXGME Jun 17, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (non-blocking): What is this used for? Some strings may only be one line, right? Maybe we should return true for NixStdString?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took the example from HCL which also always returns false. I will try to implement proper smart logic, see if that makes any difference, but I suspected it might be best to copy my reference for now 🤷


private var outSourceOffsets: IntArray? = null

override fun decode(rangeInsideHost: TextRange, outChars: StringBuilder): Boolean {
val maxIndent = NixStringUtil.detectMaxIndent(myHost.parent as NixString)
val subText: String = rangeInsideHost.substring(myHost.text)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (non-blocking): I think this is technically wrong.

If it's impossible to properly decode some chars from the specified range (e. g. if the range starts or ends inside escaped sequence), decode the longest acceptable prefix of the range and return false
— Javadoc of LiteralTextEscaper.decode

According to my understanding of the documentation, if the range starts between the first and last charachter of ''\n, we should immediately return false without modifying outChars. However, I can imagine that this is only relevant in edge cases, which may not be that important for now.

val outOffset = outChars.length
val array = IntArray(subText.length + 1)
var success = true

fun addText(text: CharSequence, offset: Int): Boolean {
for (i in text.indices) {
if (offset + i >= rangeInsideHost.startOffset) {
array[outChars.length - outOffset] = offset + i
outChars.append(text[i])
} else if (offset + i >= rangeInsideHost.endOffset) {
return false
}
}
return true
}

NixStringUtil.visit(object : NixStringUtil.StringVisitor {
override fun text(text: CharSequence, offset: Int): Boolean {
return addText(text, offset)
}

override fun escapeSequence(text: String, offset: Int, escapeSequence: CharSequence): Boolean {
val end = offset + escapeSequence.length
return if (offset < rangeInsideHost.startOffset || end > rangeInsideHost.endOffset) {
success = false
false
} else {
for (i in escapeSequence.indices) {
array[outChars.length - outOffset + i] = offset
}
outChars.append(text)
true
}
}
}, myHost, maxIndent)
// TODO Fix ArrayIndexOutOfBoundsException in the following line. (Not sure how to reproduce it.)
array[outChars.length - outOffset] = rangeInsideHost.endOffset
for (i in (outChars.length - outOffset + 1)..<array.size) {
array[i] = -1
}

outSourceOffsets = array
return success
}

override fun getOffsetInHost(offsetInDecoded: Int, rangeInsideHost: TextRange): Int {
val offsets = outSourceOffsets ?: throw IllegalStateException("#decode was not called")
val result = if (offsetInDecoded < offsets.size) offsets[offsetInDecoded] else -1
return result.coerceIn(-1..rangeInsideHost.length)
}

}
30 changes: 30 additions & 0 deletions src/main/java/org/nixos/idea/psi/NixStringManipulator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.nixos.idea.psi

import com.intellij.openapi.util.TextRange
import com.intellij.psi.AbstractElementManipulator

class NixStringManipulator : AbstractElementManipulator<NixStringText>() {

/**
* This function's result changes the original text in the host language
* when the fragment in the guest language changes
*/
override fun handleContentChange(
element: NixStringText,
range: TextRange,
newContent: String
): NixStringText {
// TODO This implementation is wrong as escaped and non-escaped strings a mixed together.
// The variable `replacement` is not supposed to be escaped,
// but `element.text` is from the Nix source code, and therefore escaped.
// We probably have to switch the call dependency and let `updateText` call this more generic method.
val escaped = newContent
val replacement = range.replace(element.text, escaped)
return element.updateText(replacement) as NixStringText
}

override fun getRangeInElement(element: NixStringText): TextRange = when {
element.textLength == 0 -> TextRange.EMPTY_RANGE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: This first case seems unreachable to me.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me too (and it should be unreachable in the HCL plugin too, but they also do it there).

My guess is they just wanted to cover all the codepaths?

Or maybe it's possible to have non-compiling code that returns zero-length tokens.

else -> TextRange.from(0, element.textLength)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import org.jetbrains.annotations.NotNull;
import org.nixos.idea.psi.NixPsiElement;

abstract class AbstractNixPsiElement extends ASTWrapperPsiElement implements NixPsiElement {
abstract public class AbstractNixPsiElement extends ASTWrapperPsiElement implements NixPsiElement {
Comment thread
JojOatXGME marked this conversation as resolved.

AbstractNixPsiElement(@NotNull ASTNode node) {
super(node);
Expand Down
62 changes: 62 additions & 0 deletions src/main/java/org/nixos/idea/psi/impl/AbstractNixString.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package org.nixos.idea.psi.impl

import com.intellij.lang.ASTNode
import com.intellij.psi.PsiLanguageInjectionHost
import org.nixos.idea.psi.NixElementFactory
import org.nixos.idea.psi.NixIndString
import org.nixos.idea.psi.NixStdString
import org.nixos.idea.psi.NixStringLiteralEscaper
import org.nixos.idea.psi.NixStringText
import org.nixos.idea.util.NixStringUtil


abstract class AbstractNixString(private val astNode: ASTNode) : PsiLanguageInjectionHost,
AbstractNixPsiElement(astNode), NixStringText {

override fun isValidHost() = true
Comment thread
JojOatXGME marked this conversation as resolved.

override fun updateText(s: String): NixStringText {
val project = project
val replacement = when (val string = parent) {
is NixStdString -> {
val escaped = buildString { NixStringUtil.escapeStd(this, s) }
NixElementFactory.createStdStringText(project, escaped)
}

is NixIndString -> {
val indent = indentForNewText(string)
val indentStart = prevSibling == null
val indentEnd = if (nextSibling == null) trailingIndent(text) ?: baseIndent(string) else indent
val escaped = buildString { NixStringUtil.escapeInd(this, s, indent, indentStart, indentEnd) }
NixElementFactory.createIndStringText(project, escaped)
}

else -> throw IllegalStateException("Unexpected parent: " + parent.javaClass)
}
return replace(replacement) as NixStringText
}

override fun createLiteralTextEscaper() = NixStringLiteralEscaper(this)

companion object {
private fun indentForNewText(string: NixIndString): Int {
return NixStringUtil.detectMaxIndent(string).takeIf { it != Int.MAX_VALUE } ?: (baseIndent(string) + 2)
}

private fun baseIndent(string: NixIndString): Int {
// TODO Detect indent of string
// This should be the indent of the line where the string starts.
return 0
}

private fun trailingIndent(str: String): Int? {
val lastLineFeed = str.lastIndexOf('\n')
val lastLine = if (lastLineFeed != -1) str.substring(lastLineFeed + 1) else null
return if (lastLine != null && lastLine.all { it == ' ' }) {
lastLine.length
} else {
null
}
}
}
}
68 changes: 68 additions & 0 deletions src/main/java/org/nixos/idea/util/NixIndStringUtil.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package org.nixos.idea.util

object NixIndStringUtil {
/**
* Unescapes the given string for use in a double-quoted string expression in the Nix Expression Language.
*
* See [Nix docs](https://nix.dev/manual/nix/2.22/language/values.html#type-string) for the logic, which
* is non-trivial.
*
* For example, `'` can be used to escape `''`, which means `'''` does not contain
* a string terminator
* ```
* $ nix eval --expr " '' ''' '' "
* "'' "
* ```
*
* This function does not erase string interpolations, because
* they are hard to parse in a loop without a proper grammar. For example:
Comment thread
JojOatXGME marked this conversation as resolved.
* ```nix
* '' ${someNixFunc "${foo "}}" }" } ''
* ```
*/
@JvmStatic
fun unescape(chars: CharSequence): String = buildString {
for ((index, c) in chars.withIndex()) {
fun prevChar() = chars.getOrNull(index - 1)
fun prev2Chars(): String? {
val prev = prevChar() ?: return null
val prevPrev = chars.getOrNull(index - 2) ?: return null
return "${prevPrev}${prev}"
}

fun prev3Chars(): String? {
val prev2 = prev2Chars() ?: return null
val prevPrev2 = chars.getOrNull(index - 3) ?: return null
return "${prevPrev2}${prev2}"
}

when (c) {
Comment thread
JojOatXGME marked this conversation as resolved.
// ''\ escapes any character, but we can only cover known ones in advance:
'\'' -> when {
// ''' is escaped to ''
prev2Chars() == "''" -> append("''")
// '' is the string delimiter
else -> continue
}

'\\' -> when {
prev2Chars() == "''" -> continue
prevChar() == '\'' -> continue
else -> append(c)
}

'$' -> if (prevChar() == '$') append(c) else continue
'{' -> if (prevChar() == '$') append("\${") else append(c)

else -> if (prev3Chars() == "''\\") when (c) {
'r' -> if (prev3Chars() == "''\\") append('\r') else append(c)
'n' -> if (prev3Chars() == "''\\") append('\n') else append(c)
't' -> if (prev3Chars() == "''\\") append('\t') else append(c)
else -> append("''\\").append(c)
} else {
append(c)
}
}
}
}
}
Loading