Repository: hannah-sten/texify-idea
Issue: "Convert to LaTeX alternative" command unexpectedly rearranged text and caused a NullPointerException

### Type of JetBrains IDE (IntelliJ, PyCharm, etc.) and version
PyCharm 2022.3.1 (build PY-223.8214.51)

### Operating System 
Windows 11 10.0 (amd64)

### TeXiFy IDEA version
0.7.25.1

### Description
I used the command to convert primitive \\bf to \\textbf.
It produced a NullPointerException with the below stack trace and had the side effect of moving the line of text following the original \\bf text into the new \\textbf. The original \\bf text is now enclosed in curly braces immediately after the new \\textbf.
I've included the relevant snippets of the source file before and after the "Convert to LaTeX alternative" command was used below.
I understand that the Tex commands used in this source file are rather archaic. I'm creating a new document based on an old document's source file to ensure the new document's style is consistent.

Before:
```latex
\\begin{center}
{\\Large \\bf{Instructions for formatting (S)PC list}}
\\end{center}

The format of the text file should be as follows. \\\\
```

After:
```latex
\\begin{center}
{\\Large \\textbf{The format of the text file should be as follows.}{Instructions for formatting (S)PC list}}
\\end{center}

 \\\\
```

### Stacktrace
```
java.lang.NullPointerException
	at nl.hannahsten.texifyidea.inspections.latex.codematurity.LatexPrimitiveStyleInspection$InspectionFix.applyFix(LatexPrimitiveStyleInspection.kt:90)
	at nl.hannahsten.texifyidea.inspections.latex.codematurity.LatexPrimitiveStyleInspection$InspectionFix.applyFix(LatexPrimitiveStyleInspection.kt:62)
	at com.intellij.codeInspection.LocalQuickFix.generatePreview(LocalQuickFix.java:99)
	at com.intellij.codeInspection.ex.QuickFixWrapper.generatePreview(QuickFixWrapper.java:135)
	at com.intellij.codeInsight.intention.impl.IntentionActionWithTextCaching$MyIntentionAction.generatePreview(IntentionActionWithTextCaching.java:241)
	at com.intellij.codeInsight.intention.impl.preview.IntentionPreviewComputable.invokePreview$lambda$3$lambda$2(IntentionPreviewComputable.kt:146)
	at com.intellij.psi.impl.source.PostprocessReformattingAspect.lambda$postponeFormattingInside$2(PostprocessReformattingAspect.java:140)
	at com.intellij.psi.impl.source.PostprocessReformattingAspect.postponeFormattingInside(PostprocessReformattingAspect.java:148)
	at com.intellij.psi.impl.source.PostprocessReformattingAspect.postponeFormattingInside(PostprocessReformattingAspect.java:139)
	at com.intellij.codeInsight.intention.impl.preview.IntentionPreviewComputable.invokePreview$lambda$3(IntentionPreviewComputable.kt:146)
	at com.intellij.codeInsight.intention.preview.IntentionPreviewUtils.previewSession(IntentionPreviewUtils.java:94)
	at com.intellij.codeInsight.intention.impl.preview.IntentionPreviewComputable.invokePreview(IntentionPreviewComputable.kt:144)
	at com.intellij.codeInsight.intention.impl.preview.IntentionPreviewComputable.generatePreview(IntentionPreviewComputable.kt:103)
	at com.intellij.codeInsight.intention.impl.preview.IntentionPreviewComputable.tryCreateDiffContent$lambda$1(IntentionPreviewComputable.kt:61)
	at com.intellij.model.SideEffectGuard.computeWithAllowedSideEffects(SideEffectGuard.java:25)
	at com.intellij.model.SideEffectGuard.computeWithoutSideEffects(SideEffectGuard.java:18)
	at com.intellij.codeInsight.intention.impl.preview.IntentionPreviewComputable.tryCreateDiffContent(IntentionPreviewComputable.kt:61)
	at com.intellij.codeInsight.intention.impl.preview.IntentionPreviewComputable.call(IntentionPreviewComputable.kt:39)
	at com.intellij.codeInsight.intention.impl.preview.IntentionPreviewComputable.call(IntentionPreviewComputable.kt:34)
	at com.intellij.openapi.application.impl.NonBlockingReadActionImpl$OTelMonitor.callWrapped(NonBlockingReadActionImpl.java:746)
	at com.intellij.openapi.application.impl.NonBlockingReadActionImpl$OTelMonitor$MonitoredComputation.call(NonBlockingReadActionImpl.java:778)
	at com.intellij.openapi.application.impl.NonBlockingReadActionImpl$Submission.insideReadAction(NonBlockingReadActionImpl.java:573)
	at com.intellij.openapi.application.impl.NonBlockingReadActionImpl$Submission.lambda$attemptComputation$3(NonBlockingReadActionImpl.java:537)
	at com.intellij.openapi.application.impl.ApplicationImpl.tryRunReadAction(ApplicationImpl.java:1086)
	at com.intellij.openapi.progress.util.ProgressIndicatorUtils.lambda$runInReadActionWithWriteActionPriority$0(ProgressIndicatorUtils.java:71)
	at com.intellij.openapi.progress.util.ProgressIndicatorUtilService.runActionAndCancelBeforeWrite(ProgressIndicatorUtilService.java:63)
	at com.intellij.openapi.progress.util.ProgressIndicatorUtils.runActionAndCancelBeforeWrite(ProgressIndicatorUtils.java:128)
	at com.intellij.openapi.progress.util.ProgressIndicatorUtils.lambda$runWithWriteActionPriority$1(ProgressIndicatorUtils.java:109)
	at com.intellij.openapi.progress.ProgressManager.lambda$runProcess$0(ProgressManager.java:68)
	at com.intellij.openapi.progress.impl.CoreProgressManager.lambda$runProcess$2(CoreProgressManager.java:188)
	at com.intellij.openapi.progress.impl.CoreProgressManager.lambda$executeProcessUnderProgress$13(CoreProgressManager.java:589)
	at com.intellij.openapi.progress.impl.CoreProgressManager.registerIndicatorAndRun(CoreProgressManager.java:664)
	at com.intellij.openapi.progress.impl.CoreProgressManager.computeUnderProgress(CoreProgressManager.java:620)
	at com.intellij.openapi.progress.impl.CoreProgressManager.executeProcessUnderProgress(CoreProgressManager.java:588)
	at com.intellij.openapi.progress.impl.ProgressManagerImpl.executeProcessUnderProgress(ProgressManagerImpl.java:60)
	at com.intellij.openapi.progress.impl.CoreProgressManager.runProcess(CoreProgressManager.java:175)
	at com.intellij.openapi.progress.ProgressManager.runProcess(ProgressManager.java:68)
	at com.intellij.openapi.progress.util.ProgressIndicatorUtils.runWithWriteActionPriority(ProgressIndicatorUtils.java:106)
	at com.intellij.openapi.progress.util.ProgressIndicatorUtils.runInReadActionWithWriteActionPriority(ProgressIndicatorUtils.java:71)
	at com.intellij.openapi.application.impl.NonBlockingReadActionImpl$Submission.attemptComputation(NonBlockingReadActionImpl.java:537)
	at com.intellij.openapi.application.impl.NonBlockingReadActionImpl$Submission.lambda$transferToBgThread$1(NonBlockingReadActionImpl.java:444)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635)
	at java.base/java.util.concurrent.Executors$PrivilegedThreadFactory$1$1.run(Executors.java:702)
	at java.base/java.util.concurrent.Executors$PrivilegedThreadFactory$1$1.run(Executors.java:699)
	at java.base/java.security.AccessController.doPrivileged(AccessController.java:399)
	at java.base/java.util.concurrent.Executors$PrivilegedThreadFactory$1.run(Executors.java:699)
	at java.base/java.lang.Thread.run(Thread.java:833)

```

Changed files:
src/nl/hannahsten/texifyidea/util/magic/CommandMagic.kt
src/nl/hannahsten/texifyidea/inspections/latex/codematurity/LatexPrimitiveStyleInspection.kt
test/nl/hannahsten/texifyidea/inspections/latex/codematurity/LatexPrimitiveStyleInspectionTest.kt

diff --git a/src/nl/hannahsten/texifyidea/inspections/latex/codematurity/LatexPrimitiveStyleInspection.kt b/src/nl/hannahsten/texifyidea/inspections/latex/codematurity/LatexPrimitiveStyleInspection.kt
index 2243abd5e..b74944420 100644
--- a/src/nl/hannahsten/texifyidea/inspections/latex/codematurity/LatexPrimitiveStyleInspection.kt
+++ b/src/nl/hannahsten/texifyidea/inspections/latex/codematurity/LatexPrimitiveStyleInspection.kt
@@ -1,21 +1,23 @@
 package nl.hannahsten.texifyidea.inspections.latex.codematurity
 
 import com.intellij.codeInspection.InspectionManager
-import com.intellij.codeInspection.LocalQuickFix
+import com.intellij.codeInspection.LocalQuickFixAndIntentionActionOnPsiElement
 import com.intellij.codeInspection.ProblemDescriptor
 import com.intellij.codeInspection.ProblemHighlightType
+import com.intellij.openapi.editor.Editor
 import com.intellij.openapi.project.Project
-import com.intellij.psi.PsiDocumentManager
+import com.intellij.psi.PsiElement
 import com.intellij.psi.PsiFile
+import com.intellij.psi.SmartPointerManager
+import com.intellij.psi.SmartPsiElementPointer
+import com.intellij.psi.util.siblings
 import nl.hannahsten.texifyidea.index.LatexCommandsIndex
 import nl.hannahsten.texifyidea.inspections.InsightGroup
 import nl.hannahsten.texifyidea.inspections.TexifyInspectionBase
 import nl.hannahsten.texifyidea.psi.LatexCommands
-import nl.hannahsten.texifyidea.psi.LatexNoMathContent
-import nl.hannahsten.texifyidea.util.deleteElement
+import nl.hannahsten.texifyidea.psi.LatexGroup
+import nl.hannahsten.texifyidea.psi.LatexPsiHelper
 import nl.hannahsten.texifyidea.util.magic.CommandMagic
-import nl.hannahsten.texifyidea.util.nextSiblingIgnoreWhitespace
-import nl.hannahsten.texifyidea.util.parentOfType
 import org.jetbrains.annotations.Nls
 
 /**
@@ -50,7 +52,7 @@ class LatexPrimitiveStyleInspection : TexifyInspectionBase() {
                 manager.createProblemDescriptor(
                     command,
                     "Use of TeX primitive " + CommandMagic.stylePrimitives[index] + " is discouraged",
-                    InspectionFix(),
+                    InspectionFix(SmartPointerManager.createPointer(command)),
                     ProblemHighlightType.GENERIC_ERROR_OR_WARNING,
                     isOntheFly
                 )
@@ -59,40 +61,58 @@ class LatexPrimitiveStyleInspection : TexifyInspectionBase() {
         return descriptors
     }
 
-    private inner class InspectionFix : LocalQuickFix {
+    private inner class InspectionFix(val oldCommand: SmartPsiElementPointer<LatexCommands>) : LocalQuickFixAndIntentionActionOnPsiElement(oldCommand.element) {
 
         @Nls
         override fun getFamilyName(): String {
             return "Convert to LaTeX alternative"
         }
 
-        override fun applyFix(
-            project: Project,
-            descriptor: ProblemDescriptor
-        ) {
-            val element = descriptor.psiElement as? LatexCommands ?: return
+        override fun getText(): String {
+            return oldCommand.element
+                ?.let { "Convert ${it.name} to ${CommandMagic.stylePrimitiveReplacements[it.name]}" }
+                ?: familyName
+        }
 
-            // Find elements that go after the primitive.
-            val cmdIndex = CommandMagic.stylePrimitives.indexOf(element.name)
-            if (cmdIndex < 0) {
-                return
-            }
-            val content = element.parentOfType(LatexNoMathContent::class)
-            val next = content!!.nextSiblingIgnoreWhitespace()
-            val after = if (next == null) "" else next.text
-            val replacement =
-                String.format(CommandMagic.stylePrimitveReplacements[cmdIndex], after)
-            val document =
-                PsiDocumentManager.getInstance(project).getDocument(element.containingFile)
+        override fun invoke(project: Project, file: PsiFile, editor: Editor?, startElement: PsiElement, endElement: PsiElement) {
+            val command = startElement as? LatexCommands ?: return
+            val newCommandName = CommandMagic.stylePrimitiveReplacements[command.name] ?: return
 
-            // Delete the ending part..
-            if (next != null) {
-                document?.deleteElement(next)
-            }
+            // Convert {help \\bf this is content} to "help \\textbf{this is content}", but leave \\bf{this is bold} alone.
+            if (command.requiredParameters.isEmpty()) {
+                val commandInFile = file.findElementAt(oldCommand.range?.startOffset ?: return) ?: return
+
+                // Find the LaTeX group surrounding this command, so we can separate its content in all the siblings
+                // before the command and all the siblings after the command.
+                val previousTrace = mutableListOf(commandInFile)
+                while (previousTrace.last().parent != null) {
+                    val parent = previousTrace.last().parent
+                    previousTrace.add(parent)
+                    if (parent is LatexGroup) break
+                }
 
-            // Replace command.
-            val range = element.commandToken.textRange
-            document?.replaceString(range.startOffset, range.endOffset, replacement)
+                val commandInContent = previousTrace.lastOrNull { it.text == commandInFile.text }
+                val prefixContent = commandInContent?.siblings(forward = false, withSelf = false)?.toList()
+                    ?.reversed()
+                    ?.joinToString(" ") { it.text }
+                    ?.trim()
+                    ?.let { if (it.isBlank()) "" else "$it " }
+                    ?: ""
+                val requiredParamContent = commandInContent?.siblings(forward = true, withSelf = false)
+                    ?.joinToString(" ") { it.text }
+                    ?.trim() ?: ""
+
+                val newPsi = LatexPsiHelper(project).createFromText(
+                    "$prefixContent$newCommandName{$requiredParamContent}"
+                ).firstChild
+
+                val parentGroup = previousTrace.last()
+                parentGroup.replace(newPsi)
+            }
+            // Convert \\bf{this is bold} to \\textbf{this is bold}
+            else {
+                command.setName(newCommandName)
+            }
         }
     }
 }
\\ No newline at end of file
diff --git a/src/nl/hannahsten/texifyidea/util/magic/CommandMagic.kt b/src/nl/hannahsten/texifyidea/util/magic/CommandMagic.kt
index 79df2c8e5..576f513be 100644
--- a/src/nl/hannahsten/texifyidea/util/magic/CommandMagic.kt
+++ b/src/nl/hannahsten/texifyidea/util/magic/CommandMagic.kt
@@ -427,11 +427,11 @@ object CommandMagic {
     )
 
     /**
-     * The LaTeX counterparts of all [stylePrimitives] commands where %s is the content.
+     * The LaTeX counterparts of all [stylePrimitives] commands.
      */
-    val stylePrimitveReplacements = listOf(
-        "\\\\textrm{%s}", "\\\\textsf{%s}", "\\\\texttt{%s}", "\\\\textit{%s}",
-        "\\\\textsl{%s}", "\\\\textsc{%s}", "\\\\textbf{%s}"
+    val stylePrimitiveReplacements = mapOf(
+        RM.cmd to "\\\\textrm", SF.cmd to "\\\\textsf", TT.cmd to "\\\\texttt", IT.cmd to "\\\\textit",
+        SL.cmd to "\\\\textsl", SC.cmd to "\\\\textsc", BF.cmd to "\\\\textbf"
     )
 
     /**
diff --git a/test/nl/hannahsten/texifyidea/inspections/latex/codematurity/LatexPrimitiveStyleInspectionTest.kt b/test/nl/hannahsten/texifyidea/inspections/latex/codematurity/LatexPrimitiveStyleInspectionTest.kt
index 8451037aa..a714a9f2d 100644
--- a/test/nl/hannahsten/texifyidea/inspections/latex/codematurity/LatexPrimitiveStyleInspectionTest.kt
+++ b/test/nl/hannahsten/texifyidea/inspections/latex/codematurity/LatexPrimitiveStyleInspectionTest.kt
@@ -2,7 +2,6 @@ package nl.hannahsten.texifyidea.inspections.latex.codematurity
 
 import nl.hannahsten.texifyidea.file.LatexFileType
 import nl.hannahsten.texifyidea.inspections.TexifyInspectionTestBase
-import nl.hannahsten.texifyidea.testutils.writeCommand
 
 class LatexPrimitiveStyleInspectionTest : TexifyInspectionTestBase(LatexPrimitiveStyleInspection()) {
 
@@ -16,23 +15,62 @@ class LatexPrimitiveStyleInspectionTest : TexifyInspectionTestBase(LatexPrimitiv
         myFixture.checkHighlighting()
     }
 
-    fun testQuickfix() {
+    fun `test simple quickfix`() {
+        testQuickFix("""{\\it is italic}""", """\\textit{is italic}""")
+    }
+
+    fun `test quickfix in group`() {
+        testQuickFix("""{help abc \\it is italic}""", """help abc \\textit{is italic}""")
+    }
+
+    fun `test file`() {
+        testQuickFix(
+            """
+            \\documentclass{article}
+
+            \\begin{document}
+                asdf {my \\bf bold text} not bold
+            \\end{document}
+            """.trimIndent(),
+            """
+            \\documentclass{article}
+
+            \\begin{document}
+                asdf my \\textbf{bold text} not bold
+            \\end{document}
+            """.trimIndent()
+        )
+    }
+
+    fun `test bf`() {
         myFixture.configureByText(
             LatexFileType,
             """
-            {\\it is italic}
+            \\begin{center}
+            {\\Large <warning descr="Use of TeX primitive \\bf is discouraged">\\bf{Instructions for formatting (S)PC list}</warning>}
+            \\end{center}
+
+            The format of the text file should be as follows. \\\\
             """.trimIndent()
         )
+        myFixture.checkHighlighting()
+    }
 
-        val quickFixes = myFixture.getAllQuickFixes()
-        assertEquals(1, quickFixes.size)
-        writeCommand(myFixture.project) {
-            quickFixes.first().invoke(myFixture.project, myFixture.editor, myFixture.file)
-        }
+    fun `test quick fix`() {
+        testQuickFix(
+            """
+            \\begin{center}
+            {\\Large \\bf{Instructions for formatting (S)PC list}}
+            \\end{center}
 
-        myFixture.checkResult(
+            The format of the text file should be as follows. \\\\
+            """.trimIndent(),
             """
-            {\\textit{is italic} }
+            \\begin{center}
+            {\\Large \\textbf{Instructions for formatting (S)PC list}}
+            \\end{center}
+
+            The format of the text file should be as follows. \\\\
             """.trimIndent()
         )
     }