Skip to content
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

Implement basic one-off language inections #82

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 14 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
142 changes: 142 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,142 @@
package org.nixos.idea.psi

import com.intellij.openapi.util.TextRange
import com.intellij.psi.LiteralTextEscaper
import com.intellij.psi.PsiLanguageInjectionHost
import org.intellij.lang.annotations.Language
import org.nixos.idea.psi.impl.AbstractNixString

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

override fun isOneLine(): Boolean = false
Copy link
Contributor

@JojOatXGME JojOatXGME Jun 17, 2024

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
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 getRelevantTextRange(): TextRange {
if (myHost.textLength <= 4) return TextRange.EMPTY_RANGE
return TextRange.create(2, myHost.textLength - 2)
}

override fun decode(rangeInsideHost: TextRange, outChars: StringBuilder): Boolean {
// TODO issue #81 only indented strings supported for now
// single line strings require a new decode function because
// it uses different escaping mechanisms
if (myHost !is NixIndString) return false

val subText: String = rangeInsideHost.substring(myHost.text)
Copy link
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 array = IntArray(subText.length + 1)
val success = unescapeAndDecode(subText, outChars, array)
outSourceOffsets = array
Copy link
Contributor

Choose a reason for hiding this comment

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

price: I like this solution of creating a lookup table.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wish I could take credit, I borrowed the idea from terraform

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(0..rangeInsideHost.length) + rangeInsideHost.startOffset
}

companion object {
/**
* Does not consider interpolations so that
* they do appear in the guest language and remain when we end up converting back to Nix.
*
* @returns the minIndent of the string if successful, or null if unsuccessful.
JojOatXGME marked this conversation as resolved.
Show resolved Hide resolved
*/
fun unescapeAndDecode(chars: String, outChars: StringBuilder, sourceOffsets: IntArray?): Boolean {
Copy link
Contributor

@JojOatXGME JojOatXGME Jul 13, 2024

Choose a reason for hiding this comment

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

note: I am not entirely happy with having two implementations to parse/unescape strings now. Anyway, that is not important right now. I may merge them at some point in the future.

assert(sourceOffsets == null || sourceOffsets.size == chars.length + 1)

var index = 0
val outOffset = outChars.length
var braces = 0
var indentSoFar = 0
val minIndent = chars.lines()
.filterNot { it.isEmpty() }
.minOfOrNull { it.takeWhile(Char::isWhitespace).count() } ?: 0


while (index < chars.length) {
fun updateOffsets(index: Int) {
if (sourceOffsets != null) {
sourceOffsets[outChars.length - outOffset] = index - 1
sourceOffsets[outChars.length - outOffset + 1] = index
}
}

var c = chars[index++]
updateOffsets(index)


if (braces > 0) {
if (c == '{') braces++
else if (c == '}') braces--
outChars.append(c)
continue
}

if (c == '\n' && index < chars.length - 1) {
// we know that the next n chars are going to be whitespace indent
index += minIndent
outChars.append(c)
if (sourceOffsets != null) {
sourceOffsets[outChars.length - outOffset] = index
}
continue
}

if (c == '\'') {
if (index == chars.length) return false
c = chars[index++]

if (c != '\'') {
// if what follows isn't another ' then we are not escaping anything,
// so we can backtrace and continue
outChars.append("\'")
index--
continue
}

if (index == chars.length) return false
c = chars[index++]

when (c) {
// '' can be escaped by prefixing it with ', i.e., '''.
'\'' -> {
outChars.append("\'")
updateOffsets(index - 1)
outChars.append(c)
}
// $ can be escaped by prefixing it with '' (that is, two single quotes), i.e., ''$.
'$' -> outChars.append(c)
'\\' -> {
if (index == chars.length) return false
c = chars[index++]
when (c) {
// Linefeed, carriage-return and tab characters can
// be written as ''\n, ''\r, ''\t, and ''\ escapes any other character.
'a' -> outChars.append(0x07.toChar())
'b' -> outChars.append('\b')
'f' -> outChars.append(0x0c.toChar())
'n' -> outChars.append('\n')
't' -> outChars.append('\t')
'r' -> outChars.append('\r')
'v' -> outChars.append(0x0b.toChar())
else -> return false
}
}

else -> return false
}
if (sourceOffsets != null) {
sourceOffsets[outChars.length - outOffset] = index
}
continue
}

outChars.append(c)
}
return true
}
}

}
34 changes: 34 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,34 @@
package org.nixos.idea.psi

import com.intellij.openapi.util.TextRange
import com.intellij.psi.AbstractElementManipulator
import com.intellij.refactoring.suggested.startOffset
import org.nixos.idea.psi.impl.AbstractNixString
import org.nixos.idea.util.NixIndStringUtil
import org.nixos.idea.util.NixStringUtil

class NixStringManipulator : AbstractElementManipulator<NixString>() {
Copy link
Contributor

Choose a reason for hiding this comment

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

question (non-blocking): Just curious, do you know why we need this? I mean, we already override AbstractNixString.updateText.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I do not know no, again I followed the example

Your guess is as good as mine!

The name of handleContentChange() makes me suspect this might be called when you chage the text inside the editor fragment of the guest language (and this function changes the corresponding string AST node in the host language)


/**
* This function's result changes the original text in the host language
* when the fragment in the guest language changes
*/
override fun handleContentChange(
element: NixString,
range: TextRange,
newContent: String
): NixString? {
val escaped = newContent
val replacement = range.replace(element.text, escaped)
return element.updateText(replacement) as? NixString
}

override fun getRangeInElement(element: NixString): TextRange = when {
element.textLength == 0 -> TextRange.EMPTY_RANGE
Copy link
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
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.

element is NixIndString && element.textLength < 4 -> TextRange(0, element.textLength)
element is NixIndString -> TextRange(2, element.textLength - 2)
// element is not IndString, so it must be StdString
element.textLength == 1 -> TextRange(0, 1)
else -> TextRange(1, element.textLength - 1)
Copy link
Contributor

@JojOatXGME JojOatXGME Jun 17, 2024

Choose a reason for hiding this comment

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

question (non-blocking): Is it important that the returned range only contains the content of the string? Just wondering because this looks as you may return the quotes as part of the range in some cases. (Specifically when the string is not closed.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

correct, I also don't know what the consequences of this are. My reference has the same.

I can always cut out quotes if you want, I have no opinion here

}
}
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 {
JojOatXGME marked this conversation as resolved.
Show resolved Hide resolved

AbstractNixPsiElement(@NotNull ASTNode node) {
super(node);
Expand Down
51 changes: 51 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,51 @@
package org.nixos.idea.psi.impl

import com.intellij.lang.ASTNode
import com.intellij.openapi.diagnostic.Logger
import com.intellij.psi.PsiLanguageInjectionHost
import com.intellij.psi.impl.source.tree.LeafPsiElement
import org.nixos.idea.psi.NixIndString
import org.nixos.idea.psi.NixString
import org.nixos.idea.psi.NixStringLiteralEscaper


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

override fun isValidHost() = true
JojOatXGME marked this conversation as resolved.
Show resolved Hide resolved

override fun updateText(s: String): NixString {
// TODO issue #81 also support single-line strings
if (this !is NixIndString) {
LOG.info("not a nix ind string")
return this
}
val originalNode = astNode.firstChildNode.treeNext.firstChildNode as? LeafPsiElement
Copy link
Contributor

Choose a reason for hiding this comment

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

issue: This method doesn't seem to escape the input, and may therefore also remove existing escape sequences. For some reason, some lines were also duplicated for me after trying it with the following code:

pkgs.writeShellScript "my-script.sh" ''
  first_of_array=''${ARRAY[0]}
  from_nix=${lib.escapeShellArg someVar}
  ''

Copy link
Contributor Author

@cottand cottand Jul 13, 2024

Choose a reason for hiding this comment

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

this is a good catch. I was aware this method did not escape the input and would remove existing escape sequences. This is because at the stage where we get the bash text, I have no way to know if the coming ${ is a bash interpolation or a Nix one, so I don't know whether it should be escaped. Likewise for newlines: If I get a \n char, should it get converted to an escaped ''\n or into an actual \n?

I think this is where the method of not injecting in the text fragment may have reached its limits :/

val minIndentInOriginal = originalNode?.text?.lines()
?.filterNot { it.isEmpty() }
?.minOfOrNull { it.takeWhile(Char::isWhitespace).count() } ?: 0

val leadingSpace = buildString { repeat(minIndentInOriginal) { append(' ') } }

val lines = s.substring(2..(s.lastIndex - 2)) // remove quotes
.lines()

// restore indent
val withIndent = lines
.withIndex()
.map { (index, line) -> if (index != 0) leadingSpace + line else line }

// if the first line was removed in the fragment, add it back to preserve a multiline string
val withLeadingBlankLine = if (lines.first().isNotEmpty()) listOf("") + withIndent else withIndent

originalNode?.replaceWithText(withLeadingBlankLine.joinToString(separator = System.lineSeparator()))
Copy link
Contributor

Choose a reason for hiding this comment

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

issue: This line throws an exception when using line feeds which don't match the default on the system. We probably have to use the line feeds configured in IntelliJ for the current file, instead of using System.lineSeparator(). Or maybe it works to always use \n. Not sure what replaceWithText expects here.

See exception I got on Windows
java.lang.AssertionError: Wrong line separators: '\r\n  first_...' at offset 0
	at com.intellij.openapi.util.text.StringUtil.assertValidSeparators(StringUtil.java:2484)
	at com.intellij.openapi.editor.impl.DocumentImpl.assertValidSeparators(DocumentImpl.java:706)
	at com.intellij.openapi.editor.impl.DocumentImpl.replaceString(DocumentImpl.java:600)
	at com.intellij.openapi.editor.impl.DocumentImpl.replaceString(DocumentImpl.java:591)
	at com.intellij.psi.impl.PsiToDocumentSynchronizer.doCommitTransaction(PsiToDocumentSynchronizer.java:212)
	at com.intellij.psi.impl.PsiToDocumentSynchronizer.lambda$commitTransaction$1(PsiToDocumentSynchronizer.java:188)
	at com.intellij.psi.impl.PsiToDocumentSynchronizer.lambda$doSync$0(PsiToDocumentSynchronizer.java:106)
	at com.intellij.psi.impl.PsiToDocumentSynchronizer.performAtomically(PsiToDocumentSynchronizer.java:124)
	at com.intellij.psi.impl.PsiToDocumentSynchronizer.doSync(PsiToDocumentSynchronizer.java:106)
	at com.intellij.psi.impl.PsiToDocumentSynchronizer.commitTransaction(PsiToDocumentSynchronizer.java:188)
	at com.intellij.pom.core.impl.PomModelImpl.commitTransaction(PomModelImpl.java:195)
	at com.intellij.pom.core.impl.PomModelImpl.lambda$runTransaction$1(PomModelImpl.java:151)
	at com.intellij.psi.impl.DebugUtil.performPsiModification(DebugUtil.java:535)
	at com.intellij.pom.core.impl.PomModelImpl.lambda$runTransaction$2(PomModelImpl.java:103)
	at com.intellij.openapi.progress.impl.CoreProgressManager.lambda$executeNonCancelableSection$2(CoreProgressManager.java:228)
	at com.intellij.openapi.progress.impl.CoreProgressManager.computeUnderProgress(CoreProgressManager.java:634)
	at com.intellij.openapi.progress.impl.CoreProgressManager.lambda$computeInNonCancelableSection$3(CoreProgressManager.java:236)
	at com.intellij.openapi.progress.Cancellation.computeInNonCancelableSection(Cancellation.java:53)
	at com.intellij.openapi.progress.impl.CoreProgressManager.computeInNonCancelableSection(CoreProgressManager.java:236)
	at com.intellij.openapi.progress.impl.CoreProgressManager.executeNonCancelableSection(CoreProgressManager.java:227)
	at com.intellij.pom.core.impl.PomModelImpl.runTransaction(PomModelImpl.java:92)
	at com.intellij.psi.impl.source.tree.ChangeUtil.prepareAndRunChangeAction(ChangeUtil.java:141)
	at com.intellij.psi.impl.source.tree.CompositeElement.replaceChild(CompositeElement.java:632)
	at com.intellij.psi.impl.source.tree.LeafElement.replaceWithText(LeafElement.java:138)
	at org.nixos.idea.psi.impl.AbstractNixString.updateText(AbstractNixString.kt:43)
	at org.nixos.idea.psi.impl.AbstractNixString.updateText(AbstractNixString.kt:12)
	at org.nixos.idea.psi.NixStringManipulator.handleContentChange(NixStringManipulator.kt:24)
	at org.nixos.idea.psi.NixStringManipulator.handleContentChange(NixStringManipulator.kt:10)
	at com.intellij.psi.ElementManipulators.handleContentChange(ElementManipulators.java:65)
	at com.intellij.psi.impl.source.tree.injected.changesHandler.CommonInjectedFileChangesHandler.updateHostElement(CommonInjectedFileChangesHandler.kt:170)
	at com.intellij.psi.impl.source.tree.injected.changesHandler.CommonInjectedFileChangesHandler.updateHostOrFail(CommonInjectedFileChangesHandler.kt:149)
	at com.intellij.psi.impl.source.tree.injected.changesHandler.CommonInjectedFileChangesHandler.commitToOriginal(CommonInjectedFileChangesHandler.kt:122)
	at com.intellij.codeInsight.intention.impl.QuickEditHandler.lambda$commitToOriginal$10(QuickEditHandler.java:343)
	at com.intellij.psi.impl.source.PostprocessReformattingAspectImpl.lambda$disablePostprocessFormattingInside$1(PostprocessReformattingAspectImpl.java:119)
	at com.intellij.psi.impl.source.PostprocessReformattingAspectImpl.disablePostprocessFormattingInside(PostprocessReformattingAspectImpl.java:128)
	at com.intellij.psi.impl.source.PostprocessReformattingAspectImpl.disablePostprocessFormattingInside(PostprocessReformattingAspectImpl.java:118)
	at com.intellij.codeInsight.intention.impl.QuickEditHandler.commitToOriginal(QuickEditHandler.java:343)
	at com.intellij.codeInsight.intention.impl.QuickEditHandler.documentChanged(QuickEditHandler.java:278)
	at com.intellij.openapi.editor.impl.DocumentImpl.lambda$changedUpdate$1(DocumentImpl.java:913)
	at com.intellij.openapi.progress.impl.CoreProgressManager.lambda$executeNonCancelableSection$2(CoreProgressManager.java:228)
	at com.intellij.openapi.progress.impl.CoreProgressManager.registerIndicatorAndRun(CoreProgressManager.java:685)
	at com.intellij.openapi.progress.impl.CoreProgressManager.computeUnderProgress(CoreProgressManager.java:641)
	at com.intellij.openapi.progress.impl.CoreProgressManager.lambda$computeInNonCancelableSection$3(CoreProgressManager.java:236)
	at com.intellij.openapi.progress.Cancellation.computeInNonCancelableSection(Cancellation.java:57)
	at com.intellij.openapi.progress.impl.CoreProgressManager.computeInNonCancelableSection(CoreProgressManager.java:236)
	at com.intellij.openapi.progress.impl.CoreProgressManager.executeNonCancelableSection(CoreProgressManager.java:227)
	at com.intellij.openapi.editor.impl.DocumentImpl.changedUpdate(DocumentImpl.java:910)
	at com.intellij.openapi.editor.impl.DocumentImpl.updateText(DocumentImpl.java:814)
	at com.intellij.openapi.editor.impl.DocumentImpl.deleteString(DocumentImpl.java:570)
	at com.intellij.openapi.editor.actions.BackspaceAction.lambda$doBackSpaceAtCaret$0(BackspaceAction.java:65)
	at com.intellij.openapi.editor.ex.util.EditorUtil.runWithAnimationDisabled(EditorUtil.java:1015)
	at com.intellij.openapi.editor.actions.BackspaceAction.doBackSpaceAtCaret(BackspaceAction.java:70)
	at com.intellij.openapi.editor.actions.BackspaceAction$Handler.executeWriteAction(BackspaceAction.java:32)
	at com.intellij.openapi.editor.actionSystem.EditorWriteActionHandler$1.run(EditorWriteActionHandler.java:42)
	at com.intellij.openapi.application.impl.ApplicationImpl.runWriteAction(ApplicationImpl.java:975)
	at com.intellij.openapi.editor.actionSystem.EditorWriteActionHandler.doExecute(EditorWriteActionHandler.java:56)
	at com.intellij.openapi.editor.actionSystem.EditorActionHandler.execute(EditorActionHandler.java:202)
	at com.intellij.database.run.SqlNotebookDeleteHandler.doExecute(SqlNotebookDeleteHandler.java:30)
	at com.intellij.openapi.editor.actionSystem.EditorActionHandler.execute(EditorActionHandler.java:202)
	at com.intellij.codeInsight.inline.completion.CancellationKeyInlineCompletionHandler.doExecute(InlineCompletionActions.kt:31)
	at com.intellij.openapi.editor.actionSystem.EditorActionHandler.execute(EditorActionHandler.java:202)
	at com.intellij.codeInsight.editorActions.BackspaceHandler.handleBackspace(BackspaceHandler.java:91)
	at com.intellij.codeInsight.editorActions.BackspaceHandler.executeWriteAction(BackspaceHandler.java:47)
	at com.intellij.openapi.editor.actionSystem.EditorWriteActionHandler$1.run(EditorWriteActionHandler.java:42)
	at com.intellij.openapi.application.impl.ApplicationImpl.runWriteAction(ApplicationImpl.java:975)
	at com.intellij.openapi.editor.actionSystem.EditorWriteActionHandler.doExecute(EditorWriteActionHandler.java:56)
	at com.intellij.openapi.editor.actionSystem.EditorActionHandler.lambda$execute$2(EditorActionHandler.java:192)
	at com.intellij.openapi.editor.actionSystem.EditorActionHandler.doIfEnabled(EditorActionHandler.java:89)
	at com.intellij.openapi.editor.actionSystem.EditorActionHandler.lambda$execute$3(EditorActionHandler.java:191)
	at com.intellij.openapi.editor.impl.CaretModelImpl.lambda$runForEachCaret$3(CaretModelImpl.java:302)
	at com.intellij.openapi.editor.impl.CaretModelImpl.doWithCaretMerging(CaretModelImpl.java:411)
	at com.intellij.openapi.editor.impl.CaretModelImpl.runForEachCaret(CaretModelImpl.java:311)
	at com.intellij.openapi.editor.impl.CaretModelImpl.runForEachCaret(CaretModelImpl.java:288)
	at com.intellij.openapi.editor.actionSystem.EditorActionHandler.execute(EditorActionHandler.java:189)
	at com.intellij.openapi.editor.actions.DeleteSelectionHandler.executeWriteAction(DeleteSelectionHandler.java:33)
	at com.intellij.openapi.editor.actionSystem.EditorWriteActionHandler$1.run(EditorWriteActionHandler.java:42)
	at com.intellij.openapi.application.impl.ApplicationImpl.runWriteAction(ApplicationImpl.java:975)
	at com.intellij.openapi.editor.actionSystem.EditorWriteActionHandler.doExecute(EditorWriteActionHandler.java:56)
	at com.intellij.openapi.editor.actionSystem.EditorActionHandler.lambda$execute$4(EditorActionHandler.java:199)
	at com.intellij.openapi.editor.actionSystem.EditorActionHandler.doIfEnabled(EditorActionHandler.java:89)
	at com.intellij.openapi.editor.actionSystem.EditorActionHandler.execute(EditorActionHandler.java:198)
	at com.intellij.codeInsight.lookup.impl.BackspaceHandler.doExecute(BackspaceHandler.java:25)
	at com.intellij.openapi.editor.actionSystem.DynamicEditorActionHandler.doExecute(DynamicEditorActionHandler.java:63)
	at com.intellij.openapi.editor.actionSystem.EditorActionHandler.lambda$execute$4(EditorActionHandler.java:199)
	at com.intellij.openapi.editor.actionSystem.EditorActionHandler.doIfEnabled(EditorActionHandler.java:89)
	at com.intellij.openapi.editor.actionSystem.EditorActionHandler.execute(EditorActionHandler.java:198)
	at com.intellij.openapi.editor.actionSystem.EditorAction.lambda$actionPerformed$0(EditorAction.java:92)
	at com.intellij.openapi.command.impl.CoreCommandProcessor.executeCommand(CoreCommandProcessor.java:225)
	at com.intellij.openapi.command.impl.CoreCommandProcessor.executeCommand(CoreCommandProcessor.java:177)
	at com.intellij.openapi.editor.actionSystem.EditorAction.actionPerformed(EditorAction.java:101)
	at com.intellij.openapi.editor.actionSystem.EditorAction.actionPerformed(EditorAction.java:77)
	at com.intellij.openapi.actionSystem.ex.ActionUtil.doPerformActionOrShowPopup(ActionUtil.java:344)
	at com.intellij.openapi.keymap.impl.ActionProcessor.performAction(ActionProcessor.java:32)
	at com.intellij.openapi.keymap.impl.IdeKeyEventDispatcher$myActionProcessor$1.performAction(IdeKeyEventDispatcher.kt:496)
	at com.intellij.openapi.keymap.impl.IdeKeyEventDispatcherKt.doPerformActionInner$lambda$4$lambda$3(IdeKeyEventDispatcher.kt:831)
	at com.intellij.openapi.application.TransactionGuardImpl.performActivity(TransactionGuardImpl.java:106)
	at com.intellij.openapi.application.TransactionGuardImpl.performUserActivity(TransactionGuardImpl.java:95)
	at com.intellij.openapi.keymap.impl.IdeKeyEventDispatcherKt.doPerformActionInner$lambda$4(IdeKeyEventDispatcher.kt:831)
	at com.intellij.openapi.actionSystem.ex.ActionUtil.performDumbAwareWithCallbacks(ActionUtil.java:381)
	at com.intellij.openapi.keymap.impl.IdeKeyEventDispatcherKt.doPerformActionInner(IdeKeyEventDispatcher.kt:829)
	at com.intellij.openapi.keymap.impl.IdeKeyEventDispatcherKt.access$doPerformActionInner(IdeKeyEventDispatcher.kt:1)
	at com.intellij.openapi.keymap.impl.IdeKeyEventDispatcher.processAction$intellij_platform_ide_impl(IdeKeyEventDispatcher.kt:559)
	at com.intellij.openapi.keymap.impl.IdeKeyEventDispatcher.processAction(IdeKeyEventDispatcher.kt:509)
	at com.intellij.openapi.keymap.impl.IdeKeyEventDispatcher.processActionOrWaitSecondStroke(IdeKeyEventDispatcher.kt:448)
	at com.intellij.openapi.keymap.impl.IdeKeyEventDispatcher.inInitState(IdeKeyEventDispatcher.kt:441)
	at com.intellij.openapi.keymap.impl.IdeKeyEventDispatcher.dispatchKeyEvent(IdeKeyEventDispatcher.kt:303)
	at com.intellij.ide.IdeEventQueue.dispatchKeyEvent(IdeEventQueue.kt:620)
	at com.intellij.ide.IdeEventQueue._dispatchEvent$lambda$11(IdeEventQueue.kt:581)
	at com.intellij.openapi.application.impl.RwLockHolder.runWithEnabledImplicitRead(RwLockHolder.kt:75)
	at com.intellij.openapi.application.impl.RwLockHolder.runWithImplicitRead(RwLockHolder.kt:67)
	at com.intellij.ide.IdeEventQueue._dispatchEvent(IdeEventQueue.kt:581)
	at com.intellij.ide.IdeEventQueue.access$_dispatchEvent(IdeEventQueue.kt:72)
	at com.intellij.ide.IdeEventQueue$dispatchEvent$processEventRunnable$1$1$1.compute(IdeEventQueue.kt:355)
	at com.intellij.ide.IdeEventQueue$dispatchEvent$processEventRunnable$1$1$1.compute(IdeEventQueue.kt:354)
	at com.intellij.openapi.progress.impl.CoreProgressManager.computePrioritized(CoreProgressManager.java:793)
	at com.intellij.ide.IdeEventQueue$dispatchEvent$processEventRunnable$1$1.invoke(IdeEventQueue.kt:354)
	at com.intellij.ide.IdeEventQueue$dispatchEvent$processEventRunnable$1$1.invoke(IdeEventQueue.kt:349)
	at com.intellij.ide.IdeEventQueueKt.performActivity$lambda$1(IdeEventQueue.kt:1014)
	at com.intellij.openapi.application.TransactionGuardImpl.performActivity(TransactionGuardImpl.java:114)
	at com.intellij.ide.IdeEventQueueKt.performActivity(IdeEventQueue.kt:1014)
	at com.intellij.ide.IdeEventQueue.dispatchEvent$lambda$7(IdeEventQueue.kt:349)
	at com.intellij.openapi.application.impl.ApplicationImpl.runIntendedWriteActionOnCurrentThread(ApplicationImpl.java:848)
	at com.intellij.ide.IdeEventQueue.dispatchEvent(IdeEventQueue.kt:391)
	at java.desktop/java.awt.EventDispatchThread.pumpOneEventForFilters(EventDispatchThread.java:207)
	at java.desktop/java.awt.EventDispatchThread.pumpEventsForFilter(EventDispatchThread.java:128)
	at java.desktop/java.awt.EventDispatchThread.pumpEventsForHierarchy(EventDispatchThread.java:117)
	at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:113)
	at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:105)
	at java.desktop/java.awt.EventDispatchThread.run(EventDispatchThread.java:92)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

hmmm I am not sure how to get a line separator from the IDEA API. I cannot use VirtualFile::getDetectedLineSeparator() so I was guessing from system.

I will replace this with \n and hope it resolves your exception :/

return this
}

override fun createLiteralTextEscaper() = NixStringLiteralEscaper(this)

companion object {
val LOG = Logger.getInstance(AbstractNixString::class.java)
}
}

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:
JojOatXGME marked this conversation as resolved.
Show resolved Hide 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.
Show resolved Hide 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)
}
}
}
}
}
5 changes: 5 additions & 0 deletions src/main/java/org/nixos/idea/util/NixStringUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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;

Expand Down Expand Up @@ -113,6 +114,10 @@ private static void parse(@NotNull StringBuilder builder, @NotNull ASTNode token
} else if (type == NixTypes.IND_STR_ESCAPE) {
assert text.length() == 3 && ("''$".contentEquals(text) || "'''".contentEquals(text)) ||
text.length() == 4 && "''\\".contentEquals(text.subSequence(0, 3)) : text;
if ("'''".contentEquals(text)){
builder.append("''");
return;
}
cottand marked this conversation as resolved.
Show resolved Hide resolved
char c = text.charAt(text.length() - 1);
builder.append(unescape(c));
} else {
Expand Down
4 changes: 4 additions & 0 deletions src/main/lang/Nix.bnf
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
// no one really needs to know that + - * / are expected at any offset
consumeTokenMethod("expr_op.*")="consumeTokenFast"

// make IndStrings language injection Hosts
implements("string")="com.intellij.psi.PsiLanguageInjectionHost"
mixin("string")="org.nixos.idea.psi.impl.AbstractNixString"

tokens = [
// This list does not contain all tokens. This list only defines the debug
// names for tokens which have a distinct text representation. Other tokens
Expand Down
4 changes: 4 additions & 0 deletions src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@

<formattingService implementation="org.nixos.idea.format.NixExternalFormatter"/>

<!-- Manipulator for injections support -->
<lang.elementManipulator forClass="org.nixos.idea.psi.NixString"
implementationClass="org.nixos.idea.psi.NixStringManipulator"/>

<notificationGroup id="NixIDEA" displayType="BALLOON"/>
</extensions>

Expand Down
37 changes: 37 additions & 0 deletions src/test/java/org/nixos/idea/util/NixIndStringUtilTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package org.nixos.idea.util;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.nixos.idea.psi.NixStringLiteralEscaper;

import static org.junit.jupiter.api.Assertions.assertEquals;

final class NixIndStringUtilTest {
@ParameterizedTest(name = "[{index}] {0} -> {1}")
@CsvSource(quoteCharacter = '|', textBlock = """
|| , ||
abc , abc
" , "
\\ , \\
\\x , \\x
a${b}c , a${b}c
|\n| , |\n|
|\r| , |\r|
|\t| , |\t|
|''\\t| , |\t|
|''\\r| , |\r|
|''\\n| , |\n|
|'''| , |''|
$$ , $$
''$ , $
# supplementary character, i.e. character form a supplementary plane,
# which needs a surrogate pair to be represented in UTF-16
\uD83C\uDF09 , \uD83C\uDF09
""")
void unescape(String escaped, String expectedResult) {
var sb = new StringBuilder();
NixStringLiteralEscaper.Companion.unescapeAndDecode(escaped, sb, null);
var str = sb.toString();
assertEquals(expectedResult, str);
}
}
Loading