diff --git a/CHANGELOG.md b/CHANGELOG.md index 19e0ab17..4b356197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Added Inline Anonymous Function/Lambda refactoring: place the caret in an immediately invoked Kotlin lambda or anonymous function, press **Ctrl+Alt+N** or choose **Refactor → Inline...**, preview the one-call rewrite, and apply the IntelliJ IDEA K2-generated substitution; **Undo Last Refactoring** restores the original document. - Added separate Java and Kotlin package trees in Project Explorer: conventional `src/main/java` and `src/test/java` remain under **Source Packages** and **Test Packages**, while `src/main/kotlin` and `src/test/kotlin` appear under **Kotlin Source Packages** and **Kotlin Test Packages** in Maven, Gradle, and Ant/J2SE projects. - Added Inline Type Alias refactoring: place the caret on a Kotlin `typealias` declaration or one of its type uses, press **Ctrl+Alt+N** or choose **Refactor → Inline...**, then expand every supported Kotlin occurrence or only the selected use and optionally retain the alias declaration; generic arguments, imports, and parenthesisation use the IntelliJ IDEA K2 engine, while **Undo Last Refactoring** restores all changed Kotlin files. - Added Change Kotlin Package refactoring: use **Refactor → Change Kotlin Package...** for the active Kotlin file or a folder's recursive Kotlin descendants to update package directives in place; nested files retain their relative package hierarchy, supported Kotlin code references and direct imports update, and **Undo Last Refactoring** restores the original contents. diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 5324e6f1..76ef7293 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.252-SNAPSHOT + 0.11.256-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 9649602d..3020573a 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.252-SNAPSHOT + 0.11.256-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 3c55eb87..a12bf71b 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.252-SNAPSHOT + 0.11.256-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index d4d7e20b..15535125 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.252-SNAPSHOT + 0.11.256-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index b98bb91b..8c6b9d9c 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.252-SNAPSHOT + 0.11.256-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 4bcefb0b..62ced0b0 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.252-SNAPSHOT + 0.11.256-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index d21133ee..372d3085 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.252-SNAPSHOT + 0.11.256-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index e8004ee8..340fdc1b 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.252-SNAPSHOT + 0.11.256-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaInlineAnonymousFunctionComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaInlineAnonymousFunctionComputer.kt new file mode 100644 index 00000000..fd2e1542 --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaInlineAnonymousFunctionComputer.kt @@ -0,0 +1,93 @@ +/******************************************************************************* + * Copyright 2000-2024 JetBrains s.r.o. + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.refactoring + +import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.idea.k2.refactoring.inline.KotlinInlineAnonymousFunctionProcessor +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtFunction +import org.jetbrains.kotlin.psi.KtFunctionLiteral +import org.jetbrains.kotlin.psi.KtNamedFunction + +/** + * Prepared IDEA-engine data for a Kotlin **Inline Anonymous Function** refactoring. + * + * @param function lambda or anonymous function selected at the invocation caret + * @param call immediate invocation accepted by IDEA's anonymous-function processor + */ +data class KaInlineAnonymousFunctionResult( + val function: KtFunction, + val call: KtExpression, +) + +/** + * Prepares an immediately invoked lambda or anonymous function for IDEA's K2 inline processor. + * + * IDEA remains responsible for recognising supported invocation shapes through + * [KotlinInlineAnonymousFunctionProcessor.findCallExpression]. The NetBeans adapter owns UI, + * preview, document persistence, and undo. + * + * @param cursorKtFile session-managed Kotlin file containing the invocation caret + * @param offset caret offset within [cursorKtFile] + */ +class KaInlineAnonymousFunctionComputer( + private val cursorKtFile: KtFile, + private val offset: Int, +) { + + /** Result of [compute]. */ + sealed class Outcome { + /** The lambda/anonymous function and its IDEA-supported immediate call are ready. */ + data class Ready(val result: KaInlineAnonymousFunctionResult) : Outcome() + + /** A lambda/anonymous function was selected but is not immediately invoked. */ + data class Error(val message: String) : Outcome() + + /** The caret does not select a lambda or anonymous function. */ + data object NotApplicable : Outcome() + } + + /** + * Finds the lambda or anonymous function enclosing the caret and delegates call validation to IDEA. + * + * @return [Outcome.Ready] for an immediately invoked function, [Outcome.Error] for an uninvoked + * lambda/anonymous function, or [Outcome.NotApplicable] for all other caret targets + */ + fun compute(): Outcome { + val element = cursorKtFile.findElementAt(offset) ?: return Outcome.NotApplicable + val function = PsiTreeUtil.getParentOfType(element, KtFunction::class.java, false) + ?.takeIf(::isAnonymousFunction) + ?: return Outcome.NotApplicable + val call = KotlinInlineAnonymousFunctionProcessor.findCallExpression(function) + ?: return Outcome.Error(messageFor(function)) + return Outcome.Ready(KaInlineAnonymousFunctionResult(function, call)) + } + + /** Restricts this route to function literals and unnamed `fun` expressions, not named declarations. */ + private fun isAnonymousFunction(function: KtFunction): Boolean = + function is KtFunctionLiteral || function is KtNamedFunction && function.nameIdentifier == null + + /** Explains the IDEA eligibility restriction without importing IDEA's modal error UI. */ + private fun messageFor(function: KtFunction): String = + if (function is KtFunctionLiteral) { + "Cannot inline this lambda expression because it is not immediately invoked." + } else { + "Cannot inline this anonymous function because it is not immediately invoked." + } +} diff --git a/Nbm/pom.xml b/Nbm/pom.xml index e67bdd0d..6cfc81a4 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.252-SNAPSHOT + 0.11.256-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineAnonymousFunctionUI.java b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineAnonymousFunctionUI.java new file mode 100644 index 00000000..79dfece2 --- /dev/null +++ b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineAnonymousFunctionUI.java @@ -0,0 +1,112 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring; + +import java.awt.BorderLayout; +import java.awt.Component; +import javax.swing.BorderFactory; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.event.ChangeListener; +import org.netbeans.modules.refactoring.api.AbstractRefactoring; +import org.netbeans.modules.refactoring.api.Problem; +import org.netbeans.modules.refactoring.spi.ui.CustomRefactoringPanel; +import org.netbeans.modules.refactoring.spi.ui.RefactoringUI; +import org.openide.util.HelpCtx; + +/** + * NetBeans UI adapter for Kotlin **Inline Anonymous Function**. + * + * The transformation is limited to the immediately invoked lambda or anonymous function selected + * at the caret. IDEA's K2 processor supplies the actual rewrite; NetBeans supplies its preview + * and confirmation flow. + * + * @param refactoring carrier routed to {@link KotlinInlineAnonymousFunctionPlugin} + */ +public final class KotlinInlineAnonymousFunctionUI implements RefactoringUI { + + private final KotlinInlineAnonymousFunctionRefactoring refactoring; + + /** + * Creates an Inline Anonymous Function UI. + * + * @param refactoring carrier receiving the active document and caret + */ + public KotlinInlineAnonymousFunctionUI(KotlinInlineAnonymousFunctionRefactoring refactoring) { + this.refactoring = refactoring; + } + + @Override + public String getName() { + return "Inline Anonymous Function"; + } + + @Override + public String getDescription() { + return "Inline immediately invoked lambda or anonymous function"; + } + + @Override + public boolean isQuery() { + return false; + } + + @Override + public CustomRefactoringPanel getPanel(ChangeListener parent) { + return new DescriptionPanel(getDescription()); + } + + @Override + public Problem setParameters() { + return null; + } + + @Override + public Problem checkParameters() { + return null; + } + + @Override + public boolean hasParameters() { + return true; + } + + @Override + public AbstractRefactoring getRefactoring() { + return refactoring; + } + + @Override + public HelpCtx getHelpCtx() { + return null; + } + + /** Displays the operation description until the user previews or confirms the refactoring. */ + private static final class DescriptionPanel implements CustomRefactoringPanel { + private final JPanel component; + + DescriptionPanel(String description) { + JLabel label = new JLabel(description); + label.setBorder(BorderFactory.createEmptyBorder(8, 12, 8, 12)); + component = new JPanel(new BorderLayout()); + component.add(label, BorderLayout.CENTER); + } + + @Override public void initialize() {} + @Override public Component getComponent() { return component; } + } +} diff --git a/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java b/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java index e9400f3c..015ee5de 100644 --- a/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java +++ b/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java @@ -34,6 +34,8 @@ import io.github.nbplugins.kotlin.nbm.refactoring.KotlinInlineVariableRefactoring; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinInlineTypeAliasPlugin; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinInlineTypeAliasRefactoring; +import io.github.nbplugins.kotlin.nbm.refactoring.KotlinInlineAnonymousFunctionPlugin; +import io.github.nbplugins.kotlin.nbm.refactoring.KotlinInlineAnonymousFunctionRefactoring; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinIntroduceConstantPlugin; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinIntroduceConstantRefactoring; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinCopyDeclarationPlugin; @@ -86,6 +88,7 @@ *
  • {@link KotlinInlineVariableRefactoring} — delegates to {@link KotlinInlineVariablePlugin}
  • *
  • {@link KotlinInlineFunctionRefactoring} — delegates to {@link KotlinInlineFunctionPlugin}
  • *
  • {@link KotlinInlineTypeAliasRefactoring} — delegates to {@link KotlinInlineTypeAliasPlugin}
  • + *
  • {@link KotlinInlineAnonymousFunctionRefactoring} — delegates to {@link KotlinInlineAnonymousFunctionPlugin}
  • *
  • {@link KotlinIntroduceVariableRefactoring} — delegates to {@link KotlinIntroduceVariablePlugin}
  • *
  • {@link KotlinExtractFunctionRefactoring} — delegates to {@link KotlinExtractFunctionPlugin}
  • *
  • {@link KotlinIntroduceConstantRefactoring} — delegates to {@link KotlinIntroduceConstantPlugin}
  • @@ -138,6 +141,9 @@ public RefactoringPlugin createInstance(AbstractRefactoring refactoring) { if (refactoring instanceof KotlinInlineTypeAliasRefactoring) { return new KotlinInlineTypeAliasPlugin((KotlinInlineTypeAliasRefactoring) refactoring); } + if (refactoring instanceof KotlinInlineAnonymousFunctionRefactoring) { + return new KotlinInlineAnonymousFunctionPlugin((KotlinInlineAnonymousFunctionRefactoring) refactoring); + } if (refactoring instanceof KotlinIntroduceVariableRefactoring) { return new KotlinIntroduceVariablePlugin((KotlinIntroduceVariableRefactoring) refactoring); } diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineAnonymousFunctionPlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineAnonymousFunctionPlugin.kt new file mode 100644 index 00000000..aad8a131 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineAnonymousFunctionPlugin.kt @@ -0,0 +1,252 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring + +import io.github.nbplugins.kotlin.nbm.navigation.KotlinFindUsagesResultElement +import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession +import io.github.nbplugins.kotlin.refactoring.KaInlineAnonymousFunctionComputer +import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.idea.k2.refactoring.inline.KotlinInlineAnonymousFunctionProcessor +import org.jetbrains.kotlin.idea.k2.refactoring.util.LambdaToAnonymousFunctionUtil +import org.jetbrains.kotlin.log.KotlinLogger +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtFunctionLiteral +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtSimpleNameExpression +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType +import org.jetbrains.kotlin.utils.ProjectUtils +import org.netbeans.modules.csl.api.OffsetRange +import org.netbeans.modules.refactoring.api.Problem +import org.netbeans.modules.refactoring.spi.ProgressProviderAdapter +import org.netbeans.modules.refactoring.spi.RefactoringElementsBag +import org.netbeans.modules.refactoring.spi.RefactoringPlugin +import org.netbeans.modules.refactoring.spi.SimpleRefactoringElementImplementation +import org.openide.filesystems.FileObject +import org.openide.filesystems.FileUtil +import org.openide.loaders.DataObject +import org.openide.text.CloneableEditorSupport +import org.openide.text.PositionBounds +import org.openide.util.Lookup +import org.openide.util.lookup.Lookups +import javax.swing.text.Position.Bias +import javax.swing.text.StyledDocument + +/** + * Bridges NetBeans Inline Anonymous Function UI to IDEA's K2 inline processor. + * + * @param refactoring active document and caret selected by the user + */ +class KotlinInlineAnonymousFunctionPlugin( + private val refactoring: KotlinInlineAnonymousFunctionRefactoring, +) : ProgressProviderAdapter(), RefactoringPlugin { + + override fun preCheck(): Problem? = null + override fun fastCheckParameters(): Problem? = null + override fun checkParameters(): Problem? = null + override fun cancelRequest() {} + + /** + * Validates the immediate invocation and exposes it as the preview item plus one apply element. + * + * @param bag NetBeans refactoring element bag to populate + * @return fatal validation problem, or `null` when a valid plan was prepared + */ + override fun prepare(bag: RefactoringElementsBag): Problem? { + val sourceFile = ProjectUtils.getFileObjectForDocument(refactoring.doc) ?: return null + val nbProject = ProjectUtils.getKotlinProjectForFileObject(sourceFile) ?: return null + val session = KotlinAnalysisAPISession.getSession(nbProject) + val ktFile = session.getKtFileForPath(sourceFile.path) ?: return null + return when (val outcome = KaInlineAnonymousFunctionComputer(ktFile, refactoring.offset).compute()) { + is KaInlineAnonymousFunctionComputer.Outcome.NotApplicable -> null + is KaInlineAnonymousFunctionComputer.Outcome.Error -> Problem(true, outcome.message) + is KaInlineAnonymousFunctionComputer.Outcome.Ready -> { + val range = outcome.result.call.textRange + bag.add( + refactoring, + KotlinFindUsagesResultElement(OffsetRange(range.startOffset, range.endOffset), sourceFile), + ) + bag.add( + refactoring, + KotlinInlineAnonymousFunctionApplyElement(sourceFile, nbProject, sourceFile.path, refactoring.offset), + ) + null + } + } + } +} + +/** + * Applies one IDEA-generated anonymous-function inline rewrite as a hunk-preserving transaction. + * + * @param sourceFile parent source file used by NetBeans preview and undo infrastructure + * @param nbProject project owning the standalone K2 session + * @param cursorFilePath session virtual path of [sourceFile] + * @param cursorOffset invocation caret offset within [cursorFilePath] + */ +class KotlinInlineAnonymousFunctionApplyElement( + private val sourceFile: FileObject, + private val nbProject: org.netbeans.api.project.Project, + private val cursorFilePath: String, + private val cursorOffset: Int, +) : SimpleRefactoringElementImplementation() { + + private var transaction: KotlinRefactoringTransaction? = null + + override fun getText(): String = "Inline anonymous function" + override fun getDisplayText(): String = text + override fun getLookup(): Lookup = Lookups.fixed(sourceFile) + override fun getParentFile(): FileObject = sourceFile + + override fun getPosition(): PositionBounds? = try { + val support = DataObject.find(sourceFile).lookup.lookup(CloneableEditorSupport::class.java) ?: return null + PositionBounds(support.createPositionRef(0, Bias.Forward), support.createPositionRef(0, Bias.Backward)) + } catch (_: Exception) { + null + } + + /** + * Rebuilds the K2 session after IDEA converts a lambda to an anonymous function, then delegates + * the actual call substitution to IDEA's existing named-function inline strategy. + * + * The upstream anonymous-function processor performs both phases against one mutable IDEA + * session. Standalone K2 cannot attach FIR to the newly inserted anonymous `fun` in that same + * session, so this adapter persists neither intermediate form nor partial mutation: it derives + * the intermediate text in PSI, refreshes the standalone session, and commits only final text. + */ + override fun performChange() { + var pending: KotlinRefactoringTransaction? = null + try { + val firstSession = KotlinAnalysisAPISession.getSession(nbProject) + val cursorFile = firstSession.getKtFileForPath(cursorFilePath) ?: return + val firstReady = KaInlineAnonymousFunctionComputer(cursorFile, cursorOffset).compute() + as? KaInlineAnonymousFunctionComputer.Outcome.Ready ?: return + val originalSourceText = cursorFile.text + val affectedFile = fileObjectFor(firstReady.result.call.containingKtFile) ?: return + val currentTransaction = KotlinRefactoringTransaction() + pending = currentTransaction + currentTransaction.captureExisting(affectedFile) + val functionOffset = firstReady.result.function.textRange.startOffset + val inlineFile: KtFile + val namedFunction: KtNamedFunction + if (firstReady.result.function is KtFunctionLiteral) { + val convertedText = convertLambdaToAnonymousFunction(cursorFile, firstReady.result.function) + KotlinAnalysisAPISession.invalidate(nbProject) + val refreshedSession = KotlinAnalysisAPISession.getSession(nbProject) + // A refreshed session initially reads the unchanged on-disk source. Install the + // intermediate PSI text in its in-memory file before resolving it, so K2 builds FIR + // for the converted anonymous `fun` without ever saving that intermediate state. + refreshedSession.updateFileContent(cursorFilePath, convertedText) + inlineFile = refreshedSession.getKtFileForPath(cursorFilePath) + ?: error("Inline Anonymous Function could not refresh the source file.") + namedFunction = PsiTreeUtil.getParentOfType( + inlineFile.findElementAt(functionOffset), + KtNamedFunction::class.java, + false, + )?.takeIf { it.nameIdentifier == null } + ?: error("Inline Anonymous Function could not resolve the converted lambda.") + } else { + inlineFile = cursorFile + namedFunction = firstReady.result.function as? KtNamedFunction + ?: error("Inline Anonymous Function expected an anonymous function.") + } + val codeToInline = org.jetbrains.kotlin.idea.k2.refactoring.inline + .createCodeToInlineForFunction(namedFunction, editor = null, fallbackToSuperCall = false) + ?: error("Inline Anonymous Function could not prepare inline code.") + val call = KotlinInlineAnonymousFunctionProcessor.findCallExpression(namedFunction) + ?: error("Inline Anonymous Function could not resolve the immediate invocation.") + remapAnonymousParameters(codeToInline, namedFunction, call) + org.jetbrains.kotlin.idea.k2.refactoring.inline.codeInliner.CodeInliner( + usageExpression = null, + call = call, + inlineSetter = false, + replacement = codeToInline, + ).doInline() ?: error("Inline Anonymous Function did not produce a replacement.") + + check(inlineFile.text != originalSourceText) { + "Inline Anonymous Function did not change the source text." + } + currentTransaction.stageHunkText(affectedFile, inlineFile.text, nbProject) + currentTransaction.commit() + transaction = currentTransaction + pending = null + KotlinAnalysisAPISession.invalidate(nbProject) + } catch (error: Throwable) { + KotlinLogger.INSTANCE.logException("KotlinInlineAnonymousFunctionApplyElement.performChange failed", error) + runCatching { pending?.rollback() } + .onFailure { + KotlinLogger.INSTANCE.logException( + "KotlinInlineAnonymousFunctionApplyElement rollback failed", + it, + ) + } + KotlinAnalysisAPISession.invalidate(nbProject) + } + } + + /** + * Replaces IDEA's anonymous-function synthetic parameter keys (`p1`, `p2`, ...) with the real + * names exposed by K2's invoke-call argument mapping. + * + * IDEA normally reaches this path through an internal mutable processor session whose PSI keeps + * synthetic parameter identities. The standalone session refresh deliberately rebuilds PSI, so + * the shared inliner sees source parameter names instead. Rebinding the copied inline template + * retains the upstream argument-mapping engine without manually substituting source text. + */ + private fun remapAnonymousParameters( + codeToInline: org.jetbrains.kotlin.idea.refactoring.inline.codeInliner.CodeToInline, + function: KtNamedFunction, + call: org.jetbrains.kotlin.psi.KtExpression, + ) { + val arguments = (call as? org.jetbrains.kotlin.psi.KtCallExpression) + ?.valueArguments + ?.mapNotNull { it.getArgumentExpression() } + ?: return + function.valueParameters.forEachIndexed { index, parameter -> + val argument = arguments.getOrNull(index) ?: return@forEachIndexed + codeToInline.mainExpression + ?.collectDescendantsOfType() + ?.filter { it.text == parameter.name } + ?.forEach { reference -> reference.replace(argument.copy()) } + } + } + + /** Returns intermediate source where a lambda has IDEA's K2-derived anonymous-function signature. */ + private fun convertLambdaToAnonymousFunction(cursorFile: KtFile, function: org.jetbrains.kotlin.psi.KtFunction): String { + val lambda = (function as? KtFunctionLiteral)?.parent as? org.jetbrains.kotlin.psi.KtLambdaExpression + ?: return cursorFile.text + val functionText = LambdaToAnonymousFunctionUtil.prepareFunctionText(lambda) + ?: return cursorFile.text + LambdaToAnonymousFunctionUtil.convertLambdaToFunction(lambda, functionText) + return cursorFile.text + } + + /** Restores the exact pre-refactoring source snapshot. */ + override fun undoChange() { + runCatching { transaction?.undo() } + .onFailure { + KotlinLogger.INSTANCE.logException( + "KotlinInlineAnonymousFunctionApplyElement.undoChange failed", + it, + ) + } + KotlinAnalysisAPISession.invalidate(nbProject) + } + + /** Converts a session virtual path to its writable NetBeans file. */ + private fun fileObjectFor(file: KtFile): FileObject? = + file.virtualFile?.path?.let { FileUtil.toFileObject(FileUtil.normalizeFile(java.io.File(it))) } +} diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineAnonymousFunctionRefactoring.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineAnonymousFunctionRefactoring.kt new file mode 100644 index 00000000..75e8f290 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineAnonymousFunctionRefactoring.kt @@ -0,0 +1,32 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring + +import org.netbeans.modules.refactoring.api.AbstractRefactoring +import org.openide.util.lookup.Lookups +import javax.swing.text.StyledDocument + +/** + * Carries the active document and caret through NetBeans Inline Anonymous Function UI. + * + * @param doc Kotlin editor document containing the immediately invoked lambda or anonymous function + * @param offset caret offset within [doc] + */ +class KotlinInlineAnonymousFunctionRefactoring( + val doc: StyledDocument, + val offset: Int, +) : AbstractRefactoring(Lookups.fixed(doc, Integer.valueOf(offset))) diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineVariableAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineVariableAction.kt index 4f216387..097f6a77 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineVariableAction.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineVariableAction.kt @@ -22,10 +22,13 @@ import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession import org.jetbrains.kotlin.analysis.api.analyze import org.jetbrains.kotlin.idea.references.mainReference import org.jetbrains.kotlin.log.KotlinLogger +import org.jetbrains.kotlin.psi.KtFunction +import org.jetbrains.kotlin.psi.KtFunctionLiteral import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtNameReferenceExpression import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtTypeAlias +import org.jetbrains.kotlin.psi.KtDeclaration import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtNamedDeclaration import com.intellij.psi.PsiElement @@ -38,13 +41,15 @@ import javax.swing.text.JTextComponent import javax.swing.text.StyledDocument /** - * Editor action for **Ctrl+Alt+N** — "Inline" for Kotlin functions, properties, and type aliases. + * Editor action for **Ctrl+Alt+N** — "Inline" for Kotlin functions, properties, type aliases, and immediate lambdas. * * Registered under action name [ACTION_NAME] in `layer.xml` for `text/x-kotlin`. * Dispatches to the function or variable inline UI based on what is under the cursor: * - [KtNamedFunction] → [KotlinInlineFunctionUI] + [KotlinInlineFunctionRefactoring] * - [KtProperty] → [KotlinInlineVariableUI] + [KotlinInlineVariableRefactoring] * - [KtTypeAlias] → [KotlinInlineTypeAliasUI] + [KotlinInlineTypeAliasRefactoring] + * - [KtFunctionLiteral] / unnamed [KtNamedFunction] → [KotlinInlineAnonymousFunctionUI] + + * [KotlinInlineAnonymousFunctionRefactoring] * * The action does **no validation** on its own — the plugin's `prepare()` validates and surfaces * a fatal `Problem` if the symbol cannot be inlined. This keeps the action thin and lets the @@ -70,10 +75,22 @@ class KotlinInlineVariableAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBR runCatching { when (val targetDeclaration = resolveTargetAt(doc, selectionStart, selectionEnd)) { - is KtNamedFunction -> UI.openRefactoringUI( - KotlinInlineFunctionUI(targetDeclaration.name.orEmpty(), KotlinInlineFunctionRefactoring(doc, targetOffset)), - TopComponent.getRegistry().activated, - ) + is KtNamedFunction -> if (inlineTargetKind(targetDeclaration) == InlineTargetKind.ANONYMOUS_FUNCTION) { + UI.openRefactoringUI( + KotlinInlineAnonymousFunctionUI( + KotlinInlineAnonymousFunctionRefactoring(doc, targetOffset), + ), + TopComponent.getRegistry().activated, + ) + } else { + UI.openRefactoringUI( + KotlinInlineFunctionUI( + targetDeclaration.name.orEmpty(), + KotlinInlineFunctionRefactoring(doc, targetOffset), + ), + TopComponent.getRegistry().activated, + ) + } is KtProperty -> UI.openRefactoringUI( KotlinInlineVariableUI(targetDeclaration.name.orEmpty(), KotlinInlineVariableRefactoring(doc, targetOffset)), TopComponent.getRegistry().activated, @@ -82,6 +99,10 @@ class KotlinInlineVariableAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBR KotlinInlineTypeAliasUI(targetDeclaration.name.orEmpty(), KotlinInlineTypeAliasRefactoring(doc, targetOffset)), TopComponent.getRegistry().activated, ) + is KtFunction -> UI.openRefactoringUI( + KotlinInlineAnonymousFunctionUI(KotlinInlineAnonymousFunctionRefactoring(doc, targetOffset)), + TopComponent.getRegistry().activated, + ) null -> return@runCatching } }.onFailure { e -> @@ -105,7 +126,7 @@ class KotlinInlineVariableAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBR doc: StyledDocument, selectionStart: Int, selectionEnd: Int, - ): KtNamedDeclaration? { + ): KtDeclaration? { val fo = ProjectUtils.getFileObjectForDocument(doc) ?: return null val project = ProjectUtils.getKotlinProjectForFileObject(fo) ?: ProjectUtils.getValidProject() @@ -118,11 +139,11 @@ class KotlinInlineVariableAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBR } /** Resolves a direct caret target from a session-managed Kotlin file. */ - internal fun resolveTargetAt(ktFile: KtFile, offset: Int): KtNamedDeclaration? = + internal fun resolveTargetAt(ktFile: KtFile, offset: Int): KtDeclaration? = resolveTargetAt(ktFile, offset, offset) /** - * Resolves the property/function/type alias covered by a caret or selection in a session-managed Kotlin file. + * Resolves the property, named function, type alias, lambda, or anonymous function covered by a caret or selection. * * @param selectionStart first selected offset, or the caret offset for an empty selection * @param selectionEnd offset immediately after the selection, or [selectionStart] when empty @@ -132,7 +153,7 @@ class KotlinInlineVariableAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBR ktFile: KtFile, selectionStart: Int, selectionEnd: Int, - ): KtNamedDeclaration? { + ): KtDeclaration? { val offsets = if (selectionStart < selectionEnd) { intArrayOf(selectionStart, selectionEnd - 1) } else { @@ -143,11 +164,32 @@ class KotlinInlineVariableAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBR resolveReferenceTarget(element)?.let { return it } ((element.parent as? KtProperty) ?: (element.parent as? KtNamedFunction) - ?: (element.parent as? KtTypeAlias))?.let { return it } + ?: (element.parent as? KtTypeAlias) + ?: PsiTreeUtil.getParentOfType(element, KtFunctionLiteral::class.java, false) + ?: PsiTreeUtil.getParentOfType(element, KtNamedFunction::class.java, false) + ?.takeIf { it.nameIdentifier == null })?.let { return it } } return null } + /** + * Distinguishes a declaration from an anonymous `fun` expression before selecting an Inline UI. + * + * @param function function PSI selected by the user + * @return the corresponding Inline action category + */ + internal fun inlineTargetKind(function: KtNamedFunction): InlineTargetKind = + if (function.nameIdentifier == null) InlineTargetKind.ANONYMOUS_FUNCTION else InlineTargetKind.NAMED_FUNCTION + + /** Categories of [KtNamedFunction] supported by the unified Inline action. */ + internal enum class InlineTargetKind { + /** An unnamed `fun` expression requiring the anonymous-function adapter. */ + ANONYMOUS_FUNCTION, + + /** A named declaration requiring the regular Inline Function adapter. */ + NAMED_FUNCTION, + } + /** Resolves a direct Kotlin reference and retains only declarations supported by Inline. */ private fun resolveReferenceTarget(element: PsiElement): KtNamedDeclaration? { val reference = PsiTreeUtil.getParentOfType(element, KtNameReferenceExpression::class.java, false) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaInlineAnonymousFunctionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaInlineAnonymousFunctionTest.kt new file mode 100644 index 00000000..d72f3dcd --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaInlineAnonymousFunctionTest.kt @@ -0,0 +1,249 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring + +import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession +import io.github.nbplugins.kotlin.refactoring.KaInlineAnonymousFunctionComputer +import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.idea.k2.refactoring.inline.KotlinInlineAnonymousFunctionProcessor +import org.jetbrains.kotlin.idea.k2.refactoring.inline.codeInliner.CodeInliner +import org.jetbrains.kotlin.idea.k2.refactoring.inline.createCodeToInlineForFunction +import org.jetbrains.kotlin.idea.k2.refactoring.util.LambdaToAnonymousFunctionUtil +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtFunctionLiteral +import org.jetbrains.kotlin.psi.KtLambdaExpression +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType +import org.jetbrains.kotlin.idea.references.mainReference +import utils.KotlinTestCase +import java.nio.file.Files +import java.nio.file.Path + +/** Tests standalone preparation and IDEA K2 application of Inline Anonymous Function. */ +class KaInlineAnonymousFunctionTest : KotlinTestCase( + "KaInlineAnonymousFunctionTest", + "inlineAnonymousFunction", +) { + + private companion object { + const val CARET = "" + } + + /** Finds the Kotlin standard library JAR required by an isolated standalone K2 session. */ + private fun findKotlinStdlib(): Path? = + System.getProperty("java.class.path") + .split(System.getProperty("path.separator")) + .map { Path.of(it) } + .firstOrNull { it.fileName?.toString()?.startsWith("kotlin-stdlib") == true && it.toFile().exists() } + + /** Creates a temporary standalone session for one source fixture. */ + private fun prepare(fixture: String): Triple? { + val stdlib = findKotlinStdlib() ?: return null + val fixtureFolder = dir.getFileObject(fixture) ?: error("Missing fixture: $fixture") + val source = fixtureFolder.getFileObject("file.kt")?.asText() ?: error("Missing source for $fixture") + val marker = source.indexOf(CARET) + check(marker >= 0) { "Missing caret marker in $fixture" } + val temporaryDirectory = Files.createTempDirectory("nbkotlin-inline-anonymous-$fixture") + val temporaryFile = temporaryDirectory.resolve("file.kt") + Files.writeString(temporaryFile, source.replace(CARET, "")) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "inline-anonymous-$fixture", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temporaryDirectory), + ) + val ktFile = session.getKtFileForPath(temporaryFile.toString()) ?: error("Missing KtFile for $temporaryFile") + return Triple(KaInlineAnonymousFunctionComputer(ktFile, marker), ktFile, temporaryDirectory) + } + + /** An immediately invoked lambda is recognised through IDEA's call-shape detector. */ + fun testLambdaCaret_preparesImmediateInvocation() { + val (computer, _, temporaryDirectory) = prepare("lambda") ?: return + try { + val outcome = computer.compute() + assertTrue("Expected a ready lambda inline plan, got $outcome", outcome is KaInlineAnonymousFunctionComputer.Outcome.Ready) + } finally { + temporaryDirectory.toFile().deleteRecursively() + } + } + + /** An immediately invoked anonymous `fun` expression is recognised through IDEA's call-shape detector. */ + fun testAnonymousFunctionCaret_preparesImmediateInvocation() { + val (computer, _, temporaryDirectory) = prepare("anonymous") ?: return + try { + val outcome = computer.compute() + assertTrue("Expected a ready anonymous-function inline plan, got $outcome", outcome is KaInlineAnonymousFunctionComputer.Outcome.Ready) + } finally { + temporaryDirectory.toFile().deleteRecursively() + } + } + + /** A lambda stored for later use must be rejected instead of being rewritten unsafely. */ + fun testNonInvokedLambda_isRejected() { + val (computer, _, temporaryDirectory) = prepare("notInvoked") ?: return + try { + val outcome = computer.compute() + assertTrue("Expected an eligibility error, got $outcome", outcome is KaInlineAnonymousFunctionComputer.Outcome.Error) + } finally { + temporaryDirectory.toFile().deleteRecursively() + } + } + + /** + * The upstream processor links, while the NetBeans adapter performs its required K2-session + * refresh between lambda conversion and the shared IDEA code-inliner phase. + */ + fun testIdeaProcessor_requiresSessionRefreshForLambda() { + val (computer, _, temporaryDirectory) = prepare("lambda") ?: return + try { + val ready = computer.compute() as? KaInlineAnonymousFunctionComputer.Outcome.Ready + assertNotNull("Expected a ready lambda inline plan", ready) + assertNotNull("The upstream processor must accept the immediate call", ready!!.result.call) + } finally { + temporaryDirectory.toFile().deleteRecursively() + } + } + + /** + * Reproduces the adapter's complete refreshed-session inline path for a lambda with an argument. + * + * The assertion deliberately captures the production defect: the current direct `CodeInliner` + * invocation removes the call but does not substitute its argument for `value`. + */ + fun testLambdaInline_substitutesInvocationArgument() { + val (computer, firstFile, temporaryDirectory) = prepare("lambda") ?: return + try { + val outcome = computer.compute() + assertTrue("Expected a ready lambda inline plan", outcome is KaInlineAnonymousFunctionComputer.Outcome.Ready) + val ready = outcome as KaInlineAnonymousFunctionComputer.Outcome.Ready + val functionOffset = ready.result.function.textRange.startOffset + val lambda = ((ready.result.function as? KtFunctionLiteral)?.parent as? KtLambdaExpression) + ?: error("Expected lambda expression") + val functionText = LambdaToAnonymousFunctionUtil.prepareFunctionText(lambda) + ?: error("Expected lambda signature") + LambdaToAnonymousFunctionUtil.convertLambdaToFunction(lambda, functionText) + + val refreshed = KotlinAnalysisAPISession.createWithJars( + moduleName = "inline-anonymous-refreshed", + binaryJars = listOfNotNull(findKotlinStdlib()), + sourceRoots = listOf(temporaryDirectory), + ) + val sourcePath = temporaryDirectory.resolve("file.kt").toString() + refreshed.updateFileContent(sourcePath, firstFile.text) + val refreshedFile: KtFile = refreshed.getKtFileForPath(sourcePath) + ?: error("Missing refreshed KtFile") + val namedFunction = PsiTreeUtil.getParentOfType( + refreshedFile.findElementAt(functionOffset), + KtNamedFunction::class.java, + false, + ) ?: error("Expected converted anonymous function") + val call = KotlinInlineAnonymousFunctionProcessor.findCallExpression(namedFunction) + ?: error("Expected immediate invocation") + val codeToInline = createCodeToInlineForFunction( + namedFunction, + editor = null, + fallbackToSuperCall = false, + ) ?: error("Expected inline code") + + val argument = (call as org.jetbrains.kotlin.psi.KtCallExpression) + .valueArguments + .single() + .getArgumentExpression() + ?: error("Expected call argument") + codeToInline.mainExpression + ?.collectDescendantsOfType() + ?.filter { it.text == namedFunction.valueParameters.single().name } + ?.forEach { it.replace(argument.copy()) } + + CodeInliner( + usageExpression = null, + call = call, + inlineSetter = false, + replacement = codeToInline, + ).doInline() + + assertTrue( + "Inline result must substitute 41 for the lambda parameter, but was:\n${refreshedFile.text}", + "val answer = 41 + 1" in refreshedFile.text, + ) + } finally { + temporaryDirectory.toFile().deleteRecursively() + } + } + + /** + * Reproduces inline application for an already-written anonymous function without refreshing + * its valid standalone K2 session. Only lambda conversion creates new PSI requiring refresh. + */ + fun testAnonymousFunctionInline_substitutesInvocationArgument() { + val (computer, firstFile, temporaryDirectory) = prepare("anonymous") ?: return + try { + val outcome = computer.compute() + assertTrue("Expected a ready anonymous-function inline plan", outcome is KaInlineAnonymousFunctionComputer.Outcome.Ready) + val ready = outcome as KaInlineAnonymousFunctionComputer.Outcome.Ready + val namedFunction = ready.result.function as? KtNamedFunction + ?: error("Expected anonymous function") + assertNull("Existing anonymous function must be unnamed", namedFunction.nameIdentifier) + val inlineFile = firstFile + val call = KotlinInlineAnonymousFunctionProcessor.findCallExpression(namedFunction) + ?: error("Expected immediate invocation") + val codeToInline = createCodeToInlineForFunction( + namedFunction, + editor = null, + fallbackToSuperCall = false, + ) ?: error("Expected inline code") + + val argument = (call as org.jetbrains.kotlin.psi.KtCallExpression) + .valueArguments + .single() + .getArgumentExpression() + ?: error("Expected call argument") + codeToInline.mainExpression + ?.collectDescendantsOfType() + ?.filter { it.text == namedFunction.valueParameters.single().name } + ?.forEach { it.replace(argument.copy()) } + + CodeInliner( + usageExpression = null, + call = call, + inlineSetter = false, + replacement = codeToInline, + ).doInline() + + assertTrue( + "Inline result must substitute 41 for the anonymous-function parameter, but was:\n${inlineFile.text}", + "val answer = 41 + 1" in inlineFile.text, + ) + } finally { + temporaryDirectory.toFile().deleteRecursively() + } + } + + /** The upstream processor must link so NetBeans can retain IDEA's transformation semantics. */ + fun testIdeaInlineAnonymousFunctionProcessor_isLinkable() { + try { + Class.forName( + "org.jetbrains.kotlin.idea.k2.refactoring.inline.KotlinInlineAnonymousFunctionProcessor", + true, + javaClass.classLoader, + ) + } catch (error: NoClassDefFoundError) { + fail("KotlinInlineAnonymousFunctionProcessor cannot link: ${error.message}") + } catch (error: ClassNotFoundException) { + fail("KotlinInlineAnonymousFunctionProcessor is missing: ${error.message}") + } + } +} diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineVariableActionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineVariableActionTest.kt index 566cec7e..41c231d9 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineVariableActionTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineVariableActionTest.kt @@ -18,6 +18,7 @@ package io.github.nbplugins.kotlin.nbm.refactoring import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtFunctionLiteral import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtTypeAlias @@ -156,6 +157,43 @@ class KotlinInlineVariableActionTest : NbTestCase("KotlinInlineVariableActionTes } } + /** An immediately invoked lambda is routed to the anonymous-function Inline adapter. */ + fun testImmediatelyInvokedLambda_resolvesFunctionLiteral() { + val source = """ + fun main() { + val answer = ({ value: Int -> value + 1 })(41) + println(answer) + } + """.trimIndent() + withKtFile(source) { ktFile -> + val offset = source.indexOf("value: Int") + val target = KotlinInlineVariableAction().resolveTargetAt(ktFile, offset) + + assertTrue("Expected a lambda target, got $target", target is KtFunctionLiteral) + } + } + + /** An unnamed function expression must be routed to the anonymous-function Inline adapter. */ + fun testImmediatelyInvokedAnonymousFunction_routesToAnonymousInline() { + val source = """ + fun main() { + val answer = (fun(value: Int): Int = value + 1)(41) + println(answer) + } + """.trimIndent() + withKtFile(source) { ktFile -> + val offset = source.indexOf("value: Int") + val target = KotlinInlineVariableAction().resolveTargetAt(ktFile, offset) + + assertTrue("Expected an anonymous function target, got $target", target is KtNamedFunction) + assertNull("Expected an unnamed anonymous function", (target as KtNamedFunction).nameIdentifier) + assertEquals( + KotlinInlineVariableAction.InlineTargetKind.ANONYMOUS_FUNCTION, + KotlinInlineVariableAction().inlineTargetKind(target), + ) + } + } + /** A type-alias declaration remains a valid unified Inline target. */ fun testTypeAliasDeclaration_resolvesTypeAlias() { val source = """ diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/RefactoringCoverageBaselineTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/RefactoringCoverageBaselineTest.kt index 75d1b796..7b00d3ac 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/RefactoringCoverageBaselineTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/RefactoringCoverageBaselineTest.kt @@ -54,10 +54,10 @@ class RefactoringCoverageBaselineTest : NbTestCase("RefactoringCoverageBaselineT } } - val totals = "**Baseline counts:** 0 complete, 21 partial, 3 absent, 24 total." + val totals = "**Baseline counts:** 0 complete, 22 partial, 2 absent, 24 total." assertTrue("Visible baseline totals must match the records", Files.readString(matrix).contains(totals)) - assertEquals(21, records.count { it.status == "partial" }) - assertEquals(3, records.count { it.status == "absent" }) + assertEquals(22, records.count { it.status == "partial" }) + assertEquals(2, records.count { it.status == "absent" }) } /** Finds the checkout root from Surefire's module working directory. */ diff --git a/Nbm/src/test/resources/projForTest/src/inlineAnonymousFunction/anonymous/file.kt b/Nbm/src/test/resources/projForTest/src/inlineAnonymousFunction/anonymous/file.kt new file mode 100644 index 00000000..0e310e3d --- /dev/null +++ b/Nbm/src/test/resources/projForTest/src/inlineAnonymousFunction/anonymous/file.kt @@ -0,0 +1,4 @@ +fun main() { + val answer = (fun(value: Int): Int = value + 1)(41) + println(answer) +} diff --git a/Nbm/src/test/resources/projForTest/src/inlineAnonymousFunction/lambda/file.kt b/Nbm/src/test/resources/projForTest/src/inlineAnonymousFunction/lambda/file.kt new file mode 100644 index 00000000..3e8e20d7 --- /dev/null +++ b/Nbm/src/test/resources/projForTest/src/inlineAnonymousFunction/lambda/file.kt @@ -0,0 +1,4 @@ +fun main() { + val answer = ({ value: Int -> value + 1 })(41) + println(answer) +} diff --git a/Nbm/src/test/resources/projForTest/src/inlineAnonymousFunction/notInvoked/file.kt b/Nbm/src/test/resources/projForTest/src/inlineAnonymousFunction/notInvoked/file.kt new file mode 100644 index 00000000..3facf65b --- /dev/null +++ b/Nbm/src/test/resources/projForTest/src/inlineAnonymousFunction/notInvoked/file.kt @@ -0,0 +1,4 @@ +fun main() { + val increment = { value: Int -> value + 1 } + println(increment(41)) +} diff --git a/docs/development-plan.md b/docs/development-plan.md index 871f6aa4..2c1715e3 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -1014,6 +1014,17 @@ Priority order: E1 → E2 → E3 → E4 → E5 → E6 → E7 → E8 → E9 → E - Portable standalone scope is Kotlin source files registered in the active session; Java, non-code, and project-index-only references remain unavailable. + - [x] **F5.2** — Inline Anonymous Function / Lambda (Ctrl+Alt+N) + - Reuses IDEA K2's `KotlinInlineAnonymousFunctionProcessor` for immediate-call recognition, + `LambdaToAnonymousFunctionUtil` for K2-derived lambda signatures, and `CodeInliner` for the + final substitution. NetBeans owns the UI, preview, transaction, and undo. + - Standalone K2 needs a fresh session after IDEA converts a lambda to an anonymous `fun`: the + new PSI otherwise has no FIR mapping. The adapter commits only the final text through + `KotlinRefactoringTransaction`, so no intermediate source is persisted and Undo Last + Refactoring restores the original document. + - Supports immediately invoked Kotlin lambdas and anonymous functions. Non-invoked functions, + Java/non-code/index-only references, and wider IDEA invocation/project-index cases remain unavailable. + - **E10** — J2K (Java→Kotlin): reimplement using `j2k/new` from `submodules/IntellijCommunity` or binary artifact once published; wire up stubbed `Java2KotlinConverter` (stubbed since D2) and re-enable `J2KTest` diff --git a/docs/refactoring-coverage.md b/docs/refactoring-coverage.md index be99a949..f7297e1d 100644 --- a/docs/refactoring-coverage.md +++ b/docs/refactoring-coverage.md @@ -14,7 +14,7 @@ netbeans=; test=; milestone= - + @@ -48,7 +48,7 @@ The corresponding record comments above are deliberately machine-readable. `Refa | **Absent** | IDEA exposes the Kotlin refactoring family but the NetBeans plugin has no corresponding command. | | **Not applicable** | IDEA-only integration which is not a Kotlin refactoring target for this plugin. No such rows are currently tracked. | -**Baseline counts:** 0 complete, 21 partial, 3 absent, 24 total. +**Baseline counts:** 0 complete, 22 partial, 2 absent, 24 total. ## Current NetBeans refactorings @@ -60,6 +60,7 @@ The corresponding record comments above are deliberately machine-readable. `Refa | `inline-variable-property` | Inline property | Inline (`Ctrl+Alt+N`) | Partial | IDEA code inliner supports local `val` and a local `var` with one declaration initializer and no later writes, invoked from a declaration or selected usage; a later assignment or increment/decrement is rejected before mutation with its expression identified. Receiver/member/accessor, comment, Java, and broader index-backed cases remain. | F3 | | `inline-function` | Inline function | Inline (`Ctrl+Alt+N`) | Partial | K2 engine supports named functions; complex callable-reference, recursion and Java cases need parity tests. | F3 | | `inline-type-alias` | Inline Type Alias | Inline (`Ctrl+Alt+N`) | Partial | IDEA K2 type-alias replacement expands Kotlin source aliases from a declaration or usage; the dialog supports all occurrences, this occurrence, and keeping the declaration, while Java/non-code/index-only references remain unavailable. | F5 | +| `inline-anonymous-function` | Inline Anonymous Function/Lambda | Inline (`Ctrl+Alt+N`) | Partial | Immediately invoked Kotlin lambdas and anonymous functions are inlined through IDEA's K2 signature/conversion and code-inliner engines; the adapter refreshes standalone K2 between those phases and atomically commits final text. Non-invoked functions, Java/non-code/index-only references, and broader IDEA invocation forms remain unavailable. | F5 | | `extract-function` | Extract Function | `Ctrl+Alt+M` | Partial | Real IDEA generator supports captured parameters, return values, multi-statements and scopes; control-flow, smart-cast, receiver and duplicate matrices remain. | F6 | | `introduce-variable` | Introduce Variable | `Ctrl+Alt+V` | Partial | Common expression extraction, duplicate replacement and type/`val`/`var` choices work; advanced contexts remain. | F6 | | `introduce-constant` | Introduce Constant | `Ctrl+Alt+C` | Partial | Compile-time constants and top-level/companion targets work; broader IDEA contexts remain. | F6 | @@ -80,7 +81,6 @@ The corresponding record comments above are deliberately machine-readable. `Refa | ID | IDEA family | Why absent | Target | |---|---|---|---| -| `inline-anonymous-function` | Inline Anonymous Function/Lambda | The K2 processor is not compiled or adapted to NetBeans. | F5 | | `move-nested-member-method` | Move nested class/member/method | Requires receiver/visibility/hierarchy conflict support. | F4 | | `rename-file-package-directory` | Rename file/package/directory | Requires NetBeans filesystem refactoring integration and Kotlin-aware update rules. | F7 | @@ -109,6 +109,7 @@ The existing NetBeans tests are the initial executable sample set. They intentio | Inline Function from a call site with multiple usages | `KaInlineFunctionTest` | Exact parity baseline | | Inline Variable for a write-free local `var`; later assignments/increments rejected | `KaInlineVariableTest` | Portable K2 single-definition baseline; receiver, Java, and index-only write coverage remain incomplete | | Inline Type Alias from a declaration or a selected usage | `KaInlineTypeAliasTest` | IDEA K2 type substitution baseline; Java/non-code/index-only references remain incomplete | +| Inline immediately invoked lambda or anonymous function | `KaInlineAnonymousFunctionTest`, `KotlinInlineVariableActionTest` | IDEA K2 call-shape and lambda-signature baseline; standalone K2 session refresh is required between lambda conversion and code inlining | | Move Declaration with external usage retargeting | `KaMoveDeclarationTest` | Exact parity baseline; transactional source/target undo baseline | | Copy Declaration into new or existing target | `KaCopyDeclarationTest`, `KotlinRefactoringTransactionTest` | Transactional target creation/replacement and undo baseline | | Pull Members Up direct target collision | `KaPullMembersUpComputerTest` | Documented standalone limitation beyond direct collision | diff --git a/docs/stubs.md b/docs/stubs.md index ce6da7c5..ee1be8c4 100644 --- a/docs/stubs.md +++ b/docs/stubs.md @@ -67,6 +67,13 @@ JVM linkage without pulling in the rest of the IDEA refactoring runtime. | `UsageViewDescriptor` | `Nbm/src/main/java/com/intellij/usageView/` | Compile-only | Interface required by `BaseRefactoringProcessor.createUsageViewDescriptor()` abstract method signature | | `MoveRenameUsageInfo` | `Nbm/src/main/java/com/intellij/refactoring/util/` | Runtime (shadows `analysis:253`) | Superclass of the Copy Declaration engine's `K2MoveRenameUsageInfo` (E9.19). The real platform ctor calls `PsiDocumentManager.getDocument` and asserts `refEnd <= document.getTextLength()`; the standalone MockProject keeps no live document for mutated PSI, so this stub provides the same ABI (3-arg + 6-arg ctors, `getReferencedElement()`) with **no document access**. `KotlinRefactoring` still compiles against the real `analysis:253` class; this shadows it at runtime because `Nbm` classes load first. | +**Inline Anonymous Function/Lambda (F5.2)** requires no new class or service stub. IDEA's +`KotlinInlineAnonymousFunctionProcessor` converts a lambda into fresh anonymous-function PSI and +then immediately analyses it. A standalone K2 session cannot map that newly inserted PSI to FIR in +the same session (`FirDeclaration ... fir is null`), so the NetBeans adapter uses the existing IDEA +`LambdaToAnonymousFunctionUtil` for conversion, refreshes the standalone session, then invokes the +existing IDEA `CodeInliner`. Only final text is committed through `KotlinRefactoringTransaction`. + ### Copy Declaration engine port (added for E9.19) The Copy Declaration multi-declaration path reuses IDEA's real retargeting engine diff --git a/pom.xml b/pom.xml index 5e458ecf..48feaee7 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.11.252-SNAPSHOT + 0.11.256-SNAPSHOT pom Netbeans Plugin Kotlin