-
-
Notifications
You must be signed in to change notification settings - Fork 30
Implement basic one-off language inections #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
4448751
02610cb
1848120
6b6df0e
a442904
d709114
88c8dce
9bc2749
82d8b4c
6d88fd6
d8086c8
f97fee3
43664e2
d675347
ade4a64
7d2f79b
f47a403
328d644
ce8ee54
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 + "__)"; | ||
| } | ||
| } |
| 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 | ||
|
|
||
| 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (non-blocking): I think this is technically wrong.
According to my understanding of the documentation, if the range starts between the first and last charachter of |
||
| 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) | ||
| } | ||
|
|
||
| } | ||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. note: This first case seems unreachable to me.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
|---|---|---|
| @@ -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 | ||
|
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 | ||
| } | ||
| } | ||
| } | ||
| } | ||
| 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: | ||
|
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) { | ||
|
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) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
trueforNixStdString?There was a problem hiding this comment.
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 🤷