diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 746a96e5c..70b7f009b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,6 +13,7 @@ jobs: name: Qodana # Note: updating to ubuntu-latest may slow the build and produce a "The runner has received a shutdown signal." runs-on: ubuntu-20.04 + if: github.event.pull_request.draft == false steps: - uses: actions/checkout@v3 with: diff --git a/src/nl/hannahsten/texifyidea/refactoring/LatexRefactoringSupportProvider.kt b/src/nl/hannahsten/texifyidea/refactoring/LatexRefactoringSupportProvider.kt index 14084c070..7fe14e979 100644 --- a/src/nl/hannahsten/texifyidea/refactoring/LatexRefactoringSupportProvider.kt +++ b/src/nl/hannahsten/texifyidea/refactoring/LatexRefactoringSupportProvider.kt @@ -2,7 +2,9 @@ package nl.hannahsten.texifyidea.refactoring import com.intellij.lang.refactoring.RefactoringSupportProvider import com.intellij.psi.PsiElement +import com.intellij.refactoring.RefactoringActionHandler import nl.hannahsten.texifyidea.psi.LatexParameterText +import nl.hannahsten.texifyidea.refactoring.introducecommand.LatexExtractCommandHandler /** * This class is used to enable inline refactoring. @@ -20,4 +22,6 @@ class LatexRefactoringSupportProvider : RefactoringSupportProvider() { override fun isSafeDeleteAvailable(element: PsiElement): Boolean { return element is LatexParameterText } + + override fun getIntroduceVariableHandler(): RefactoringActionHandler = LatexExtractCommandHandler() } \ No newline at end of file diff --git a/src/nl/hannahsten/texifyidea/refactoring/introducecommand/LatexExtractCommandHandler.kt b/src/nl/hannahsten/texifyidea/refactoring/introducecommand/LatexExtractCommandHandler.kt new file mode 100644 index 000000000..c310fd4d7 --- /dev/null +++ b/src/nl/hannahsten/texifyidea/refactoring/introducecommand/LatexExtractCommandHandler.kt @@ -0,0 +1,289 @@ +package nl.hannahsten.texifyidea.refactoring.introducecommand + +import com.intellij.ide.plugins.PluginManagerCore.isUnitTestMode +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Pass +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiNamedElement +import com.intellij.psi.util.PsiTreeUtil.findCommonParent +import com.intellij.psi.util.elementType +import com.intellij.psi.util.parents +import com.intellij.refactoring.IntroduceTargetChooser +import com.intellij.refactoring.RefactoringActionHandler +import com.intellij.refactoring.RefactoringBundle +import com.intellij.refactoring.introduce.inplace.OccurrencesChooser +import com.intellij.refactoring.suggested.startOffset +import com.intellij.refactoring.util.CommonRefactoringUtil +import nl.hannahsten.texifyidea.file.LatexFile +import nl.hannahsten.texifyidea.psi.* +import nl.hannahsten.texifyidea.psi.LatexTypes.NORMAL_TEXT_WORD +import nl.hannahsten.texifyidea.util.files.findExpressionAtCaret +import nl.hannahsten.texifyidea.util.files.findExpressionInRange +import nl.hannahsten.texifyidea.util.insertCommandDefinition +import nl.hannahsten.texifyidea.util.parser.* +import nl.hannahsten.texifyidea.util.runWriteCommandAction +import org.jetbrains.annotations.TestOnly + +/** + * Extract the selected piece of text into a \newcommand definition and replace usages. + * + * Based on code from https://github.com/intellij-rust/intellij-rust/blob/b18aab90317564307829f3c9c8e0188817a377ad/src/main/kotlin/org/rust/ide/refactoring/extraxtExpressionUi.kt#L1 + * and https://github.com/intellij-rust/intellij-rust/blob/b18aab90317564307829f3c9c8e0188817a377ad/src/main/kotlin/org/rust/ide/refactoring/extraxtExpressionUtils.kt#L1 + */ +class LatexExtractCommandHandler : RefactoringActionHandler { + override fun invoke(project: Project, editor: Editor, file: PsiFile, dataContext: DataContext?) { + if (file !is LatexFile) return + val exprs = findCandidateExpressionsToExtract(editor, file) + + // almost never happens, so the error will be likely worded wrong, but hopefully that will generate more bug reports! + if (exprs.isEmpty()) { + val message = RefactoringBundle.message( + if (editor.selectionModel.hasSelection()) + "selected.block.should.represent.an.expression" + else + "refactoring.introduce.selection.error" + ) + val title = "Introduce Custom Command" + val helpId = "refactoring.extractVariable" + CommonRefactoringUtil.showErrorHint(project, editor, message, title, helpId) + } + else { + val extractor = { expr: LatexExtractablePSI -> + extractExpression( + editor, expr, RefactoringBundle.message("introduce.variable.title") + ) + } + if (exprs.size == 1) { + extractor(exprs.single()) + } + // if there are multiple candidates (ie the user did not have an active selection, ask for them to choose what to extract + else showExpressionChooser(editor, exprs) { + extractor(it) + } + } + } + + override fun invoke(project: Project, elements: Array, dataContext: DataContext?) { } +} + +fun showExpressionChooser( + editor: Editor, + candidates: List, + callback: (LatexExtractablePSI) -> Unit +) { + if (isUnitTestMode) { + callback(MOCK!!.chooseTarget(candidates)) + } + else + IntroduceTargetChooser.showChooser( + editor, + candidates, + callback.asPass, + { it.text.substring(it.extractableIntRange) }, + RefactoringBundle.message("introduce.target.chooser.expressions.title"), + { (it as LatexExtractablePSI).extractableRangeInFile } + ) +} + +fun extractExpression( + editor: Editor, + expr: LatexExtractablePSI, + commandName: String +) { + if (!expr.isValid) return + val occurrences = expr.findOccurrences() + showOccurrencesChooser(editor, expr, occurrences) { occurrencesToReplace -> + ExpressionReplacer(expr.project, editor, expr) + .replaceElementForAllExpr(occurrencesToReplace, commandName) + } +} + +private class ExpressionReplacer( + private val project: Project, + private val editor: Editor, + private val chosenExpr: LatexExtractablePSI +) { + private val psiFactory = LatexPsiHelper(project) + + /** + * This actually replaces all the ocurrences + */ + fun replaceElementForAllExpr( + exprs: List, + commandName: String + ) { + // cache file in case the psi tree breaks + val containingFile = chosenExpr.containingFile + runWriteCommandAction(project, commandName) { + val definitionToken = insertCommandDefinition( + containingFile, + chosenExpr.text.substring(chosenExpr.extractableIntRange) + ) + ?: return@runWriteCommandAction + exprs.filter { it != chosenExpr }.forEach { + val newItem = it.text.replace( + chosenExpr.text.substring(chosenExpr.extractableIntRange), + "\\mycommand" + ) + it.replace(psiFactory.createFromText(newItem).firstChild) + } + val newItem = chosenExpr.text.replace( + chosenExpr.text.substring(chosenExpr.extractableIntRange), + "\\mycommand" + ) + chosenExpr.replace(psiFactory.createFromText(newItem).firstChild) + + val definitionOffset = definitionToken.textRange + + PsiDocumentManager.getInstance(project).doPostponedOperationsAndUnblockDocument(editor.document) + + // sometimes calling the previous line will invalidate `definitionToken`, so we will make sure to find the actual valid token + val vampireCommandDefinition = containingFile.findExpressionAtCaret(definitionOffset.startOffset) + ?: throw IllegalStateException("Unexpectedly could not find an expression") + + val actualToken = + vampireCommandDefinition + .childrenOfType(PsiNamedElement::class) + .filterIsInstance() + .firstOrNull { it.text == "\\mycommand" } + ?: throw IllegalStateException("Psi Tree was not in the expected state") + + editor.caretModel.moveToOffset(actualToken.textRange.startOffset) + + // unsure where title is used. Either way, put the user into a refactor where they get to specify the new command name + LatexInPlaceVariableIntroducer( + actualToken, editor, project, "Choose a Variable" + ) + .performInplaceRefactoring(LinkedHashSet()) + } + } +} + +fun showOccurrencesChooser( + editor: Editor, + expr: LatexExtractablePSI, + occurrences: List, + callback: (List) -> Unit +) { + if (isUnitTestMode && occurrences.size > 1) { + callback(MOCK!!.chooseOccurrences(expr, occurrences)) + } + else { + OccurrencesChooser.simpleChooser(editor) + .showChooser( + expr, + occurrences, + { choice: OccurrencesChooser.ReplaceChoice -> + val toReplace = if (choice == OccurrencesChooser.ReplaceChoice.ALL) occurrences else listOf(expr) + callback(toReplace) + }.asPass + ) + } +} + +// Pass is deprecated, but IntroduceTargetChooser.showChooser doesnt have compatible signatures to replace with consumer yet +private val ((T) -> Unit).asPass: Pass + get() = object : Pass() { + override fun pass(t: T) = this@asPass(t) + } + +/** + * Returns a list of "expressions" which could be extracted. + */ +fun findCandidateExpressionsToExtract(editor: Editor, file: LatexFile): List { + val selection = editor.selectionModel + // if the user has highlighted a block, simply return that + if (selection.hasSelection()) { + // If there's an explicit selection, suggest only one expression + return listOfNotNull(file.findExpressionInRange(selection.selectionStart, selection.selectionEnd)) + } + else { + val expr = file.findExpressionAtCaret(editor.caretModel.offset) + ?: return emptyList() + // if expr is a \begin, return the whole block it is a part of, and just assume since the cursor was there that it was meant to be + if (expr is LatexBeginCommand) { + val endCommand = expr.endCommand() + return if (endCommand == null) + emptyList() + else { + val environToken = findCommonParent(expr, endCommand) + if (environToken != null) + listOf(environToken.asExtractable()) + else + emptyList() + } + } + // if this was text, like in a command parameter, only ofer itself + else if (expr is LatexNormalText) { + return listOf(expr.asExtractable()) + } + else { + // if inside a text block, we will offer the current word, current sentence, current line, whole block, and applicable parents + if (expr.elementType == NORMAL_TEXT_WORD) { + // variable where we will build up our return + val out = arrayListOf(expr.asExtractable()) + + val interruptedParent = expr.firstParentOfType(LatexNormalText::class) + ?: expr.firstParentOfType(LatexParameterText::class) + ?: return emptyList() + val interruptedText = interruptedParent.text + // in this text block, if it multiline, find current line + if (interruptedText.contains('\n')) { + val previousLineBreak = + interruptedText.substring(0, editor.caretModel.offset - interruptedParent.startOffset) + .lastIndexOf('\n') + val startIndex = previousLineBreak + 1 + interruptedText.substring(previousLineBreak + 1) + .indexOfFirst { !it.isWhitespace() } + val nextNewlineindex = interruptedText.substring(startIndex).indexOf('\n') + val endOffset = if (nextNewlineindex == -1) + interruptedParent.textLength + else + startIndex + nextNewlineindex + out.add(interruptedParent.asExtractable(TextRange(startIndex, endOffset))) + } + + // if this text is in a math context, offer the math environ + val mathParent = expr.firstParentOfType(LatexInlineMath::class) + if (mathParent != null) { + val mathChild = mathParent.firstChildOfType(LatexMathContent::class) + if (mathChild != null) + out.add(mathChild.asExtractable()) + out.add(mathParent.asExtractable()) + } + out.add(interruptedParent.asExtractable()) + return out.distinctBy { it.text.substring(it.extractableIntRange) } + } + // default behavior: offer to extract any parent that we consider "extractable" + else + return expr.parents(true) + .takeWhile { it.elementType == NORMAL_TEXT_WORD || it is LatexNormalText || it is LatexParameter || it is LatexMathContent || it is LatexCommandWithParams } + .distinctBy { it.text } + .map { it.asExtractable() } + .toList() + } + } +} + +interface ExtractExpressionUi { + fun chooseTarget(exprs: List): LatexExtractablePSI + fun chooseOccurrences(expr: LatexExtractablePSI, occurrences: List): List +} + +// This allows us to run tests and mimic user input +var MOCK: ExtractExpressionUi? = null + +@TestOnly +fun withMockTargetExpressionChooser(mock: ExtractExpressionUi, f: () -> Unit) { + MOCK = mock + try { + f() + } + finally { + MOCK = null + } +} diff --git a/src/nl/hannahsten/texifyidea/refactoring/introducecommand/LatexInPlaceVariableIntroducer.kt b/src/nl/hannahsten/texifyidea/refactoring/introducecommand/LatexInPlaceVariableIntroducer.kt new file mode 100644 index 000000000..a5a1a9e72 --- /dev/null +++ b/src/nl/hannahsten/texifyidea/refactoring/introducecommand/LatexInPlaceVariableIntroducer.kt @@ -0,0 +1,27 @@ +package nl.hannahsten.texifyidea.refactoring.introducecommand + +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.NlsContexts +import com.intellij.openapi.util.Pair +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiNamedElement +import com.intellij.refactoring.introduce.inplace.InplaceVariableIntroducer + +class LatexInPlaceVariableIntroducer( + elementToRename: PsiNamedElement, + editor: Editor, + project: Project, + @NlsContexts.Command title: String, + private val additionalElementsToRename: List = emptyList() +) : InplaceVariableIntroducer(elementToRename, editor, project, title, emptyArray(), null) { + + override fun collectAdditionalElementsToRename(stringUsages: MutableList>) { + for (element in additionalElementsToRename) { + if (element.isValid) { + stringUsages.add(Pair(element, TextRange(0, element.textLength))) + } + } + } +} \ No newline at end of file diff --git a/src/nl/hannahsten/texifyidea/util/Commands.kt b/src/nl/hannahsten/texifyidea/util/Commands.kt index 60dea0c5f..bfee1ebd0 100644 --- a/src/nl/hannahsten/texifyidea/util/Commands.kt +++ b/src/nl/hannahsten/texifyidea/util/Commands.kt @@ -1,19 +1,22 @@ package nl.hannahsten.texifyidea.util import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import com.intellij.psi.search.GlobalSearchScope import nl.hannahsten.texifyidea.index.LatexCommandsIndex import nl.hannahsten.texifyidea.index.LatexDefinitionIndex -import nl.hannahsten.texifyidea.lang.commands.LatexCommand -import nl.hannahsten.texifyidea.lang.commands.LatexRegularCommand -import nl.hannahsten.texifyidea.lang.commands.RequiredFileArgument +import nl.hannahsten.texifyidea.lang.commands.* import nl.hannahsten.texifyidea.psi.LatexCommands import nl.hannahsten.texifyidea.psi.LatexParameter import nl.hannahsten.texifyidea.psi.LatexPsiHelper +import nl.hannahsten.texifyidea.util.PackageUtils.getDefaultInsertAnchor +import nl.hannahsten.texifyidea.util.files.commandsInFile +import nl.hannahsten.texifyidea.util.files.definitions import nl.hannahsten.texifyidea.util.labels.getLabelDefinitionCommands import nl.hannahsten.texifyidea.util.magic.CommandMagic import nl.hannahsten.texifyidea.util.magic.EnvironmentMagic +import nl.hannahsten.texifyidea.util.magic.cmd import nl.hannahsten.texifyidea.util.parser.* import java.util.stream.Collectors @@ -38,6 +41,45 @@ fun getIncludeCommands(): Set { .toSet() } +/** + * Inserts a custom c custom command definition. + */ +fun insertCommandDefinition(file: PsiFile, commandText: String, newCommandName: String = "mycommand"): PsiElement? { + if (!file.isWritable) return null + + val commands = file.commandsInFile() + + var last: LatexCommands? = null + for (cmd in commands) { + if (cmd.name == LatexNewDefinitionCommand.NEWCOMMAND.cmd) { + last = cmd + } + else if (cmd.name == LatexGenericRegularCommand.USEPACKAGE.cmd) { + last = cmd + } + else if (cmd.name == LatexGenericRegularCommand.BEGIN.cmd && cmd.requiredParameter(0) == "document") { + last = cmd + break + } + } + + val blockingNames = file.definitions().filter { it.commandToken.text.matches("${newCommandName}\\d*".toRegex()) } + + val nonConflictingName = "${newCommandName}${if (blockingNames.isEmpty()) "" else blockingNames.size.toString()}" + val command = "\\newcommand{\\$nonConflictingName}{$commandText}\n" + + val newChild = LatexPsiHelper(file.project).createFromText(command).firstChild + val newNode = newChild.node + + // The anchor after which the new element will be inserted + // When there are no usepackage commands: insert below documentclass. + val (anchorAfter, _) = getDefaultInsertAnchor(commands, last) + + PackageUtils.insertNodeAfterAnchor(file, anchorAfter, prependNewLine = true, newNode, prependBlankLine = true) + + return newChild +} + /** * Expand custom commands in a given text once, using its definition in the [LatexCommandsIndex]. */ @@ -48,7 +90,10 @@ fun expandCommandsOnce(inputText: String, project: Project, file: PsiFile?): Str for (command in commandsInText) { // Expand the command once, and replace the command with the expanded text - val commandExpansion = LatexCommandsIndex.getCommandsByNames(file ?: return null, *CommandMagic.commandDefinitionsAndRedefinitions.toTypedArray()) + val commandExpansion = LatexCommandsIndex.getCommandsByNames( + file ?: return null, + *CommandMagic.commandDefinitionsAndRedefinitions.toTypedArray() + ) .firstOrNull { it.getRequiredArgumentValueByName("cmd") == command.text } ?.getRequiredArgumentValueByName("def") text = text.replace(command.text, commandExpansion ?: command.text) diff --git a/src/nl/hannahsten/texifyidea/util/General.kt b/src/nl/hannahsten/texifyidea/util/General.kt index f27a28d15..62f93cb7a 100644 --- a/src/nl/hannahsten/texifyidea/util/General.kt +++ b/src/nl/hannahsten/texifyidea/util/General.kt @@ -4,6 +4,7 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.command.WriteCommandAction import com.intellij.openapi.project.Project import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiFile import java.util.regex.Pattern /** @@ -32,6 +33,16 @@ fun runWriteCommandAction(project: Project, writeCommandAction: () -> Unit) { WriteCommandAction.runWriteCommandAction(project, writeCommandAction) } +fun runWriteCommandAction( + project: Project, + commandName: String, + vararg files: PsiFile, + writeCommandAction: () -> T +): T { + return WriteCommandAction.writeCommandAction(project, *files).withName(commandName) + .compute(writeCommandAction) +} + /** * Converts an [IntRange] to [TextRange]. */ @@ -47,7 +58,8 @@ val IntRange.length: Int * Converts the range to a range representation with the given seperator. * When the range has size 0, it will only print the single number. */ -fun IntRange.toRangeString(separator: String = "-") = if (start == endInclusive) start else "$start$separator$endInclusive" +fun IntRange.toRangeString(separator: String = "-") = + if (start == endInclusive) start else "$start$separator$endInclusive" /** * Shift the range to the right by the number of places given. @@ -59,7 +71,7 @@ fun IntRange.shiftRight(displacement: Int): IntRange { /** * Converts a [TextRange] to [IntRange]. */ -fun TextRange.toIntRange() = startOffset..endOffset +fun TextRange.toIntRange() = startOffset until endOffset /** * Easy access to [java.util.regex.Matcher.matches]. diff --git a/src/nl/hannahsten/texifyidea/util/Packages.kt b/src/nl/hannahsten/texifyidea/util/Packages.kt index 5bef1065a..dc6a9919c 100644 --- a/src/nl/hannahsten/texifyidea/util/Packages.kt +++ b/src/nl/hannahsten/texifyidea/util/Packages.kt @@ -1,10 +1,12 @@ package nl.hannahsten.texifyidea.util +import com.intellij.lang.ASTNode import com.intellij.openapi.project.Project import com.intellij.openapi.util.text.StringUtil import com.intellij.psi.PsiDocumentManager import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile +import com.intellij.psi.impl.source.tree.TreeUtil import nl.hannahsten.texifyidea.index.file.LatexExternalPackageInclusionCache import nl.hannahsten.texifyidea.lang.LatexPackage import nl.hannahsten.texifyidea.lang.commands.LatexGenericRegularCommand @@ -14,6 +16,7 @@ import nl.hannahsten.texifyidea.settings.TexifySettings import nl.hannahsten.texifyidea.util.files.* import nl.hannahsten.texifyidea.util.magic.CommandMagic import nl.hannahsten.texifyidea.util.magic.PackageMagic +import nl.hannahsten.texifyidea.util.magic.cmd import nl.hannahsten.texifyidea.util.parser.firstParentOfType import nl.hannahsten.texifyidea.util.parser.toStringMap @@ -34,6 +37,36 @@ object PackageUtils { ?.split(";") ?.toList() ?: emptyList() + /** + * Get the default psi element to insert new packages/definitions after. + * The anchor will be the given preferred anchor if not null. + */ + fun getDefaultInsertAnchor(commands: Collection, preferredAnchor: LatexCommands?): Pair { + val classHuh = commands.asSequence() + .filter { cmd -> + cmd.name == LatexGenericRegularCommand.DOCUMENTCLASS.cmd || cmd.name == LatexGenericRegularCommand.LOADCLASS.cmd + } + .firstOrNull() + val anchorAfter: PsiElement? + val prependNewLine: Boolean + if (classHuh != null) { + anchorAfter = classHuh + prependNewLine = true + } + else { + // No other sensible location can be found + anchorAfter = null + prependNewLine = false + } + + return if (preferredAnchor == null) { + Pair(anchorAfter, prependNewLine) + } + else { + Pair(preferredAnchor, true) + } + } + /** * Inserts a usepackage statement for the given package in a certain file. * @@ -69,33 +102,7 @@ object PackageUtils { } } - val prependNewLine: Boolean - // The anchor after which the new element will be inserted - val anchorAfter: PsiElement? - - // When there are no usepackage commands: insert below documentclass. - if (last == null) { - val classHuh = commands.asSequence() - .filter { cmd -> - "\\documentclass" == cmd.commandToken - .text || "\\LoadClass" == cmd.commandToken.text - } - .firstOrNull() - if (classHuh != null) { - anchorAfter = classHuh - prependNewLine = true - } - else { - // No other sensible location can be found - anchorAfter = null - prependNewLine = false - } - } - // Otherwise, insert below the lowest usepackage. - else { - anchorAfter = last - prependNewLine = true - } + val (anchorAfter, prependNewLine) = getDefaultInsertAnchor(commands, last) var command = commandName command += if (parameters == null || "" == parameters) "" else "[$parameters]" @@ -103,6 +110,21 @@ object PackageUtils { val newNode = LatexPsiHelper(file.project).createFromText(command).firstChild.node + insertNodeAfterAnchor(file, anchorAfter, prependNewLine, newNode) + } + + /** + * Insert an AST node after a certain anchor, possibly with a newline. + * + * @param prependBlankLine If prependNewLine is true, you can set this to true to insert an additional blank line. + */ + fun insertNodeAfterAnchor( + file: PsiFile, + anchorAfter: PsiElement?, + prependNewLine: Boolean, + newNode: ASTNode, + prependBlankLine: Boolean = false + ) { // Don't run in a write action, as that will produce a SideEffectsNotAllowedException for INVOKE_LATER // Avoid "Attempt to modify PSI for non-committed Document" @@ -111,18 +133,21 @@ object PackageUtils { .doPostponedOperationsAndUnblockDocument(file.document() ?: return) PsiDocumentManager.getInstance(file.project).commitDocument(file.document() ?: return) runWriteAction { + val newlineText = if (prependBlankLine) "\n\n" else "\n" + val newLine = LatexPsiHelper(file.project).createFromText(newlineText).firstChild.node // Avoid NPE, see #3083 (cause unknown) - if (anchorAfter != null && com.intellij.psi.impl.source.tree.TreeUtil.getFileElement(anchorAfter.parent.node) != null) { + if (anchorAfter != null && TreeUtil.getFileElement(anchorAfter.parent.node) != null) { val anchorBefore = anchorAfter.node.treeNext - @Suppress("KotlinConstantConditions") - if (prependNewLine) { - val newLine = LatexPsiHelper(file.project).createFromText("\n").firstChild.node + if (prependNewLine || prependBlankLine) { anchorAfter.parent.node.addChild(newLine, anchorBefore) } anchorAfter.parent.node.addChild(newNode, anchorBefore) } else { // Insert at beginning + if (prependNewLine || prependBlankLine) { + file.node.addChild(newLine, file.firstChild.node) + } file.node.addChild(newNode, file.firstChild.node) } } diff --git a/src/nl/hannahsten/texifyidea/util/files/PsiFile.kt b/src/nl/hannahsten/texifyidea/util/files/PsiFile.kt index f55a54e00..6cc0aa1a8 100644 --- a/src/nl/hannahsten/texifyidea/util/files/PsiFile.kt +++ b/src/nl/hannahsten/texifyidea/util/files/PsiFile.kt @@ -6,30 +6,27 @@ import com.intellij.openapi.fileEditor.FileEditor import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.TextEditor import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.openapi.util.TextRange import com.intellij.openapi.vfs.VirtualFile -import com.intellij.psi.PsiDocumentManager -import com.intellij.psi.PsiFile -import com.intellij.psi.PsiManager +import com.intellij.psi.* import com.intellij.psi.search.GlobalSearchScope -import nl.hannahsten.texifyidea.file.BibtexFileType -import nl.hannahsten.texifyidea.file.ClassFileType -import nl.hannahsten.texifyidea.file.LatexFileType -import nl.hannahsten.texifyidea.file.StyleFileType +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.psi.util.elementType +import com.intellij.psi.util.parents +import com.intellij.refactoring.suggested.endOffset +import com.intellij.refactoring.suggested.startOffset +import nl.hannahsten.texifyidea.file.* import nl.hannahsten.texifyidea.index.LatexCommandsIndex import nl.hannahsten.texifyidea.index.LatexDefinitionIndex import nl.hannahsten.texifyidea.index.LatexEnvironmentsIndex import nl.hannahsten.texifyidea.index.LatexIncludesIndex import nl.hannahsten.texifyidea.lang.LatexPackage -import nl.hannahsten.texifyidea.psi.LatexCommands -import nl.hannahsten.texifyidea.psi.LatexEnvironment +import nl.hannahsten.texifyidea.psi.* import nl.hannahsten.texifyidea.reference.InputFileReference import nl.hannahsten.texifyidea.run.bibtex.BibtexRunConfiguration import nl.hannahsten.texifyidea.util.* import nl.hannahsten.texifyidea.util.magic.FileMagic -import nl.hannahsten.texifyidea.util.parser.allCommands -import nl.hannahsten.texifyidea.util.parser.getIncludedFiles -import nl.hannahsten.texifyidea.util.parser.isDefinition -import nl.hannahsten.texifyidea.util.parser.requiredParameter +import nl.hannahsten.texifyidea.util.parser.* /** * Get the file search scope for this psi file. @@ -268,4 +265,56 @@ fun PsiFile.getBibtexRunConfigurations() = project .filter { it.mainFile == findRootFile().virtualFile } .flatMap { it.bibRunConfigs } .map { it.configuration } - .filterIsInstance() \ No newline at end of file + .filterIsInstance() + +/** + * Gets the smallest extractable expression at the given offset + */ +fun PsiFile.expressionAtOffset(offset: Int): PsiElement? { + val element = findElementAt(offset) ?: return null + + return element.parents(true) + .firstOrNull { it.elementType == LatexTypes.NORMAL_TEXT_WORD || it is LatexNormalText || it is LatexParameter || it is LatexMathContent || it is LatexCommandWithParams } +} + +/** + * Get "expression" within range specified. An expression is either a PsiElement, or a PsiElement with a specific extraction range in the case that the range lies entirely within a text block + */ +fun PsiFile.findExpressionInRange(startOffset: Int, endOffset: Int): LatexExtractablePSI? { + val firstUnresolved = findElementAt(startOffset) ?: return null + val startElement = + if (firstUnresolved is PsiWhiteSpace) + findElementAt(firstUnresolved.endOffset) ?: return null + else + firstUnresolved + + val lastUnresolved = findElementAt(endOffset - 1) ?: return null + val endElement = + if (lastUnresolved is PsiWhiteSpace) + findElementAt(lastUnresolved.startOffset - 1) ?: return null + else + lastUnresolved + + val commonParent = PsiTreeUtil.findCommonParent(startElement, endElement) ?: return null + + // We will consider an exression to be a sentence or a substring out of text. Here we will mark that in the extraction range. + return if (commonParent is LatexNormalText) { + commonParent.asExtractable(TextRange(startOffset - commonParent.startOffset, endOffset - commonParent.startOffset)) + } + else + commonParent.asExtractable() +} + +/** + * Attempts to find the "expression" at the given offset + */ +fun PsiFile.findExpressionAtCaret(offset: Int): PsiElement? { + val expr = expressionAtOffset(offset) + val exprBefore = expressionAtOffset(offset - 1) + return when { + expr == null -> exprBefore + exprBefore == null -> expr + PsiTreeUtil.isAncestor(expr, exprBefore, false) -> exprBefore + else -> expr + } +} \ No newline at end of file diff --git a/src/nl/hannahsten/texifyidea/util/parser/LatexExtractablePSI.kt b/src/nl/hannahsten/texifyidea/util/parser/LatexExtractablePSI.kt new file mode 100644 index 000000000..f8136411f --- /dev/null +++ b/src/nl/hannahsten/texifyidea/util/parser/LatexExtractablePSI.kt @@ -0,0 +1,33 @@ +package nl.hannahsten.texifyidea.util.parser + +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiElement +import com.intellij.refactoring.suggested.startOffset +import nl.hannahsten.texifyidea.util.toIntRange + +/** + * A wrapper for [PsiElement] that includes a range to extract. This allows extracting subtext from a text block or other non-total extracitons + */ +class LatexExtractablePSI( + val self: PsiElement, + /** + * The range of text to extract relative to the start of my text + */ + val extractableRange: TextRange = TextRange(0, self.textLength) +) : PsiElement by self { + /** + * The range of text to extract relative to the file's start. + */ + val extractableRangeInFile + get() = TextRange( + startOffset + extractableRange.startOffset, + startOffset + extractableRange.endOffset + ) + + val extractableIntRange + get() = extractableRange.toIntRange() +} + +fun PsiElement.asExtractable(): LatexExtractablePSI = LatexExtractablePSI(this) + +fun PsiElement.asExtractable(range: TextRange): LatexExtractablePSI = LatexExtractablePSI(this, range) \ No newline at end of file diff --git a/src/nl/hannahsten/texifyidea/util/parser/LatexPsiUtil.kt b/src/nl/hannahsten/texifyidea/util/parser/LatexPsiUtil.kt index c5cc9e8e0..49d7028cc 100644 --- a/src/nl/hannahsten/texifyidea/util/parser/LatexPsiUtil.kt +++ b/src/nl/hannahsten/texifyidea/util/parser/LatexPsiUtil.kt @@ -1,7 +1,10 @@ package nl.hannahsten.texifyidea.util.parser +import com.intellij.codeInsight.PsiEquivalenceUtil import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile +import com.intellij.psi.PsiRecursiveElementVisitor +import nl.hannahsten.texifyidea.file.LatexFile import nl.hannahsten.texifyidea.lang.Environment import nl.hannahsten.texifyidea.psi.* import nl.hannahsten.texifyidea.util.files.commandsInFileSet @@ -130,6 +133,31 @@ val LatexParameterText.command: PsiElement? return this.firstParentOfType(LatexCommands::class)?.firstChild } -// fun LatexOptionalKeyValPair.getKeyValValue() { -// PsiTreeUtil.getChildOfType(this, LatexKeyValValue::class.java) -// } \ No newline at end of file +/** + * @see PsiElement.findOccurrences(PsiElement) + */ +fun PsiElement.findOccurrences(): List { + val parent = firstParentOfType(LatexFile::class) + ?: return emptyList() + return this.findOccurrences(parent) +} + +/** + * Known weakness: since we will allow the user to extract portions of a text block, this will only extract text when the parent PSI's are identical. + * However, extracting a single word does not suffer from this as we are extracting an actual token. + */ +fun PsiElement.findOccurrences(searchRoot: PsiElement): List { + val visitor = object : PsiRecursiveElementVisitor() { + val foundOccurrences = ArrayList() + override fun visitElement(element: PsiElement) { + if (PsiEquivalenceUtil.areElementsEquivalent(this@findOccurrences, element)) { + foundOccurrences.add(element) + } + else { + super.visitElement(element) + } + } + } + searchRoot.acceptChildren(visitor) + return visitor.foundOccurrences.map { it.asExtractable() } +} \ No newline at end of file diff --git a/test/nl/hannahsten/texifyidea/refactoring/IntroduceVariableTest.kt b/test/nl/hannahsten/texifyidea/refactoring/IntroduceVariableTest.kt new file mode 100644 index 000000000..acd7da70b --- /dev/null +++ b/test/nl/hannahsten/texifyidea/refactoring/IntroduceVariableTest.kt @@ -0,0 +1,221 @@ +package nl.hannahsten.texifyidea.refactoring + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import nl.hannahsten.texifyidea.file.LatexFileType +import nl.hannahsten.texifyidea.refactoring.introducecommand.ExtractExpressionUi +import nl.hannahsten.texifyidea.util.parser.LatexExtractablePSI +import nl.hannahsten.texifyidea.refactoring.introducecommand.withMockTargetExpressionChooser + +class IntroduceVariableTest : BasePlatformTestCase() { + fun testBasicCaret() = doTest( + """ + My favorite number is 5.25 + """, + listOf("5.25", "My favorite number is 5.25"), + 0, + """ + \newcommand{\mycommand}{5.25} + + My favorite number is \mycommand + """ + ) + + fun testBasicSelection() = doTest( + """ + My favorite number is 5.25 + """, + emptyList(), + 0, + """ + \newcommand{\mycommand}{5.25} + + My favorite number is \mycommand + """ + ) + + fun testSentenceOffer() = doTest( + """ + \documentclass[11pt]{article} + + \begin{document} + + \chapter{The significance of 2.718, or e} + + 2.718 is a special number. + + ${'$'}\lim_{x \to \infty} (1+\frac{1}{x})^{x}=2.718${'$'} + + Some old guy discovered 2.718 and was amazed! + + Not to be confused with 2.714, or ${'$'}3\pi!-6\pi${'$'} + + \end{document} + """, + listOf( + "2.718", + "Some old guy discovered 2.718 and was amazed!", + "Some old guy discovered 2.718 and was amazed!\n\n Not to be confused with 2.714, or" + ), + 0, + """ + \documentclass[11pt]{article} + + \newcommand{\mycommand}{2.718} + + \begin{document} + + \chapter{The significance of \mycommand, or e} + + \mycommand is a special number. + + ${'$'}\lim_{x \to \infty} (1+\frac{1}{x})^{x}=\mycommand${'$'} + + Some old guy discovered \mycommand and was amazed! + + Not to be confused with 2.714, or ${'$'}3\pi!-6\pi${'$'} + + \end{document} + """, + true + ) + + fun testMultiTableSelection() = doTest( + """ + \documentclass[11pt]{article} + \begin{document} + + ${'$'}5.25 * 2.68291 = 450${'$'} + + \begin{table}[ht!] + \begin{tabular}{| r |} + \hline + 2.68291 \\ + \hline + \end{tabular} + \end{table} + + \begin{table}[ht!] + \begin{tabular}{| r |} + \hline + 2.68291 \\ + \hline + \end{tabular} + \end{table} + + Some may wonder why 2.68291 is so special. + \end{document} + """, + emptyList(), + 0, + """ + \documentclass[11pt]{article} + + \newcommand{\mycommand}{2.68291} + \begin{document} + + ${'$'}5.25 * \mycommand = 450${'$'} + + \begin{table}[ht!] + \begin{tabular}{| r |} + \hline + \mycommand \\ + \hline + \end{tabular} + \end{table} + + \begin{table}[ht!] + \begin{tabular}{| r |} + \hline + \mycommand \\ + \hline + \end{tabular} + \end{table} + + Some may wonder why \mycommand is so special. + \end{document} + """, + true + ) + + fun testWithQuotes() = doTest( + """ + We could not find strategies that would be of great assistance in this category. + However, if you ever find yourself reading ``Test With Quotes'' I thinnk you for your service. + """, + emptyList(), + 0, + """ + \newcommand{\mycommand}{Test With Quotes} + + We could not find strategies that would be of great assistance in this category. + However, if you ever find yourself reading ``\mycommand'' I thinnk you for your service. + """ + ) + + fun testEnvironmentEnumerate() = doTest( + """ + Hello Werld + + \begin{enumerate} + \item{Page Data: page id, namespace, title (File Schema: enwiki-latest-page.sql.gz)} + \item{Link Data: originating page, originating namespace, target page, target namespace (File Schema: enwiki-latest-pagelinks.sql.gz)} + \item{Redirect Data: originating page, originating namespace, target page, target namespace (File Schema: enwiki-latest-redirect.sql.gz)} + \end{enumerate} + """, + emptyList(), + 0, + """ + \newcommand{\mycommand}{\begin{enumerate} + \item{Page Data: page id, namespace, title (File Schema: enwiki-latest-page.sql.gz)} + \item{Link Data: originating page, originating namespace, target page, target namespace (File Schema: enwiki-latest-pagelinks.sql.gz)} + \item{Redirect Data: originating page, originating namespace, target page, target namespace (File Schema: enwiki-latest-redirect.sql.gz)} + \end{enumerate}} + + Hello Werld + + \mycommand + """ + ) + + private fun doTest( + before: String, + expressions: List, + target: Int, + after: String, + replaceAll: Boolean = false, + ) { + var shownTargetChooser = false + withMockTargetExpressionChooser(object : ExtractExpressionUi { + override fun chooseTarget(exprs: List): LatexExtractablePSI { + shownTargetChooser = true + println("saw") + exprs.forEach { println("'" + it.text.substring(it.extractableIntRange) + "'") } + println("xpect") + expressions.map { println("'" + it + "'") } + assertEquals( + exprs.map { it.text.substring(it.extractableIntRange).trimIndent() }, + expressions.map { it.trimIndent() } + ) + return exprs[target] + } + + override fun chooseOccurrences( + expr: LatexExtractablePSI, + occurrences: List + ): List = + if (replaceAll) occurrences else listOf(expr) + }) { + myFixture.configureByText(LatexFileType, before.trimIndent()) + /* println("'" + before + "'") + println(before.trimIndent()) + println("'" + after + "'") + println(after.trimIndent())*/ + myFixture.performEditorAction("IntroduceVariable") + myFixture.checkResult(after.trimIndent()) + + check(expressions.isEmpty() || shownTargetChooser) { + "Chooser isn't shown" + } + } + } +} \ No newline at end of file