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

Extract value refactoring #3251

Merged
merged 31 commits into from
Nov 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
40f3fd0
Extract variable pt 1.
jojo2357 Sep 19, 2023
e500780
Freestyle when no selection
jojo2357 Sep 19, 2023
5d62bbd
Search entire file
jojo2357 Sep 19, 2023
31b6f4d
Clean up some
jojo2357 Sep 19, 2023
561558f
Put the definition in the correct place
jojo2357 Sep 19, 2023
18a5291
Put the definition in the correct place
jojo2357 Sep 20, 2023
d8ed722
Added some tests
jojo2357 Sep 20, 2023
65be3c0
Fix tests
jojo2357 Sep 20, 2023
25974f0
Fix semantic error and failing test
jojo2357 Sep 20, 2023
0fe8675
Add new test for extracting an environment
jojo2357 Sep 20, 2023
6c3e285
Don't run Qodana on draft PRs
PHPirates Sep 21, 2023
3c7913e
Foundation to extract text-index-specific data
jojo2357 Sep 22, 2023
088837b
Highlighting MAGIC
jojo2357 Sep 22, 2023
efbdb3f
Fix Int Range
jojo2357 Sep 22, 2023
f957f16
Improve tests
jojo2357 Sep 22, 2023
80d8f4e
Dont offer something twice
jojo2357 Sep 23, 2023
68cfc51
distinct better
jojo2357 Sep 23, 2023
7b3646e
Add comment to LatexExtractCommandHandler
PHPirates Sep 24, 2023
7b42ab3
Fix test
jojo2357 Sep 24, 2023
a044540
All tests passing
jojo2357 Sep 26, 2023
7749240
Lint
jojo2357 Sep 26, 2023
c68f42d
Fix parameter error
jojo2357 Oct 1, 2023
e7db958
Remove unnecessary `{}`
jojo2357 Oct 1, 2023
a229dde
Pass tests
jojo2357 Oct 1, 2023
dc610ae
Distribute utilities
jojo2357 Oct 1, 2023
434909a
Reduce complexity, increase performance
jojo2357 Oct 1, 2023
caf0ed5
Clean Up
jojo2357 Oct 17, 2023
a4dade0
Reduce code duplication
PHPirates Nov 24, 2023
a7bd6fd
Remove hostility and clean up
PHPirates Nov 24, 2023
a25b6f5
Capitalization
PHPirates Nov 24, 2023
18ea366
Formatting
PHPirates Nov 24, 2023
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
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -20,4 +22,6 @@ class LatexRefactoringSupportProvider : RefactoringSupportProvider() {
override fun isSafeDeleteAvailable(element: PsiElement): Boolean {
return element is LatexParameterText
}

override fun getIntroduceVariableHandler(): RefactoringActionHandler = LatexExtractCommandHandler()
}
Original file line number Diff line number Diff line change
@@ -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<out PsiElement>, dataContext: DataContext?) { }
}

fun showExpressionChooser(
editor: Editor,
candidates: List<LatexExtractablePSI>,
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<LatexExtractablePSI>,
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<LatexCommands>()
.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<LatexExtractablePSI>,
callback: (List<LatexExtractablePSI>) -> Unit
) {
if (isUnitTestMode && occurrences.size > 1) {
callback(MOCK!!.chooseOccurrences(expr, occurrences))
}
else {
OccurrencesChooser.simpleChooser<PsiElement>(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> ((T) -> Unit).asPass: Pass<T>
get() = object : Pass<T>() {
override fun pass(t: T) = this@asPass(t)
}

/**
* Returns a list of "expressions" which could be extracted.
*/
fun findCandidateExpressionsToExtract(editor: Editor, file: LatexFile): List<LatexExtractablePSI> {
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>): LatexExtractablePSI
fun chooseOccurrences(expr: LatexExtractablePSI, occurrences: List<LatexExtractablePSI>): List<LatexExtractablePSI>
}

// 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
}
}
Original file line number Diff line number Diff line change
@@ -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<PsiElement> = emptyList()
) : InplaceVariableIntroducer<PsiElement>(elementToRename, editor, project, title, emptyArray(), null) {

override fun collectAdditionalElementsToRename(stringUsages: MutableList<in Pair<PsiElement, TextRange>>) {
for (element in additionalElementsToRename) {
if (element.isValid) {
stringUsages.add(Pair(element, TextRange(0, element.textLength)))
}
}
}
}
Loading
Loading