diff --git a/.gitignore b/.gitignore index 3a07dee1..0e416038 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ .kotlin .qodana build -codecocoon.yml \ No newline at end of file +codecocoon.yml +.codecocoon-memory \ No newline at end of file diff --git a/codecocoon.example.yml b/codecocoon.example.yml index 1bbe3dfe..d295c195 100644 --- a/codecocoon.example.yml +++ b/codecocoon.example.yml @@ -4,6 +4,13 @@ projectRoot: "/path/to/project/root" # Optional: limit transformations to these files (relative to the root). Leave empty to target the entire project files: [] +# Optional: directory where memory files are stored (for deterministic rename transformations) +# If not specified, defaults to '.codecocoon-memory' in the same directory as this config file +# Can be: +# - Absolute path: "/absolute/path/to/memory" +# - Relative path: "my-memory-dir" (relative to this config file's directory) +memoryDir: ".codecocoon-memory" + # The transformation pipeline. Order matters. Each transformation has: # - id: unique identifier # - config: arbitrary nested settings; only the selected transformation should interpret it diff --git a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/executor/TransformationExecutor.kt b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/executor/TransformationExecutor.kt index f05e396a..f4db910a 100644 --- a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/executor/TransformationExecutor.kt +++ b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/executor/TransformationExecutor.kt @@ -1,6 +1,7 @@ package com.github.pderakhshanfar.codecocoonplugin.executor import com.github.pderakhshanfar.codecocoonplugin.common.FileContext +import com.github.pderakhshanfar.codecocoonplugin.memory.Memory import com.github.pderakhshanfar.codecocoonplugin.transformation.Transformation /** @@ -13,10 +14,12 @@ interface TransformationExecutor { * * @param transformation The transformation to apply * @param context The file context + * @param memory Optional persistent memory for storing transformation state (e.g., rename mappings) * @return Result of the transformation */ suspend fun execute( transformation: Transformation, context: FileContext, + memory: Memory? = null, ): TransformationResult } \ No newline at end of file diff --git a/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/Memory.kt b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/Memory.kt new file mode 100644 index 00000000..7b0bf59f --- /dev/null +++ b/core/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/Memory.kt @@ -0,0 +1,38 @@ +package com.github.pderakhshanfar.codecocoonplugin.memory + +/** + * Generic persistent storage interface for key-value pairs. + * + * Implements [AutoCloseable] to ensure automatic persistence on resource cleanup. + * Use with `.use {}` blocks to guarantee data is saved: + * + * ```kotlin + * PersistentMemory(projectName, memoryDir).use { memory -> + * memory.put("key", "value") + * // memory.save() called automatically on close + * } + * ``` + * + * @param K Key type + * @param V Value type + */ +interface Memory : AutoCloseable { + + /** + * Retrieves the value associated with the given key, or null if not found. + */ + fun get(key: K): V? + + /** + * Puts a new key-value pair into the memory, returning the previous value if it existed. + */ + fun put(key: K, value: V): V? + + fun size(): Int + + fun save() + + override fun close() { + save() + } +} diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt index a0695ef3..99f97894 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/appstarter/HeadlessModeStarter.kt @@ -125,9 +125,7 @@ class HeadlessModeStarter : ApplicationStarter { * * This function sets up predefined transformations that are available for use. * Each transformation is identified by a unique ID and is associated with a factory - * function that creates an instance of the transformation when invoked. Specifically, - * this implementation registers the "add-comment-transformation," which adds comments - * to the beginning of files. + * function that creates an instance of the transformation when invoked. * * The registration process ensures that the transformation is correctly mapped by its * unique ID in the registry, allowing it to be referenced dynamically during execution. diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/executor/IntelliJTransformationExecutor.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/executor/IntelliJTransformationExecutor.kt index 14bf7245..8f1e372b 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/executor/IntelliJTransformationExecutor.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/executor/IntelliJTransformationExecutor.kt @@ -6,6 +6,7 @@ import com.github.pderakhshanfar.codecocoonplugin.executor.TransformationExecuto import com.github.pderakhshanfar.codecocoonplugin.executor.TransformationResult import com.github.pderakhshanfar.codecocoonplugin.intellij.psi.psiFile import com.github.pderakhshanfar.codecocoonplugin.intellij.vfs.findVirtualFile +import com.github.pderakhshanfar.codecocoonplugin.memory.Memory import com.github.pderakhshanfar.codecocoonplugin.transformation.Transformation import com.intellij.openapi.application.ReadResult import com.intellij.openapi.application.readAction @@ -26,11 +27,14 @@ class IntelliJTransformationExecutor( override suspend fun execute( transformation: Transformation, - context: FileContext + context: FileContext, + memory: Memory? ): TransformationResult { return try { when (transformation) { - is IntelliJAwareTransformation -> executeIntelliJTransformation(transformation, context) + is IntelliJAwareTransformation -> { + executeIntelliJTransformation(transformation, context, memory) + } else -> TransformationResult.Failure("Transformation ${transformation.id} must implement `IntelliJAwareTransformation`") } } catch (err: Exception) { @@ -42,6 +46,7 @@ class IntelliJTransformationExecutor( private suspend fun executeIntelliJTransformation( transformation: IntelliJAwareTransformation, context: FileContext, + memory: Memory? ): TransformationResult { val virtualFile = project.findVirtualFile(context) ?: return TransformationResult.Failure( @@ -55,7 +60,7 @@ class IntelliJTransformationExecutor( } ?: return TransformationResult.Failure("Cannot get PSI for file: ${context.relativePath}") // Run transformation directly - self-managed transformations handle EDT requirements internally - return transformation.apply(psiFile, virtualFile) + return transformation.apply(psiFile, virtualFile, memory) } // Regular transformations need writeCommandAction wrapper @@ -66,7 +71,7 @@ class IntelliJTransformationExecutor( ) writeCommandAction(project, transformation.id) { - transformation.apply(psiFile, virtualFile) + transformation.apply(psiFile, virtualFile, memory) } } } diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/AddCommentTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/AddCommentTransformation.kt index 056aa2cf..3b31d07c 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/AddCommentTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/AddCommentTransformation.kt @@ -3,6 +3,7 @@ package com.github.pderakhshanfar.codecocoonplugin.components.transformations import com.github.pderakhshanfar.codecocoonplugin.common.Language import com.github.pderakhshanfar.codecocoonplugin.executor.TransformationResult import com.github.pderakhshanfar.codecocoonplugin.intellij.psi.document +import com.github.pderakhshanfar.codecocoonplugin.memory.Memory import com.github.pderakhshanfar.codecocoonplugin.transformation.TextBasedTransformation import com.github.pderakhshanfar.codecocoonplugin.transformation.require import com.intellij.openapi.vfs.VirtualFile @@ -31,6 +32,7 @@ class AddCommentTransformation( override fun apply( psiFile: PsiFile, virtualFile: VirtualFile, + memory: Memory? ): TransformationResult = try { val comment = createComment(psiFile.name, message) val document = psiFile.document() diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/IntelliJAwareTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/IntelliJAwareTransformation.kt index a3752ce3..126393c7 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/IntelliJAwareTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/IntelliJAwareTransformation.kt @@ -1,6 +1,7 @@ package com.github.pderakhshanfar.codecocoonplugin.components.transformations import com.github.pderakhshanfar.codecocoonplugin.executor.TransformationResult +import com.github.pderakhshanfar.codecocoonplugin.memory.Memory import com.github.pderakhshanfar.codecocoonplugin.transformation.Transformation import com.intellij.openapi.application.readAction import com.intellij.openapi.vfs.VirtualFile @@ -23,11 +24,13 @@ interface IntelliJAwareTransformation : Transformation { * * @param psiFile The PSI representation of the file * @param virtualFile The virtual file being transformed + * @param memory Optional persistent memory for storing transformation state * @return Result of the transformation */ fun apply( psiFile: PsiFile, - virtualFile: VirtualFile + virtualFile: VirtualFile, + memory: Memory? = null ): TransformationResult companion object { diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/MoveFileIntoSuggestedDirectoryTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/MoveFileIntoSuggestedDirectoryTransformation.kt index bf5ac441..23276f2f 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/MoveFileIntoSuggestedDirectoryTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/MoveFileIntoSuggestedDirectoryTransformation.kt @@ -7,6 +7,7 @@ import com.github.pderakhshanfar.codecocoonplugin.components.transformations.Mov import com.github.pderakhshanfar.codecocoonplugin.executor.TransformationResult import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout import com.github.pderakhshanfar.codecocoonplugin.java.JavaTransformation +import com.github.pderakhshanfar.codecocoonplugin.memory.Memory import com.github.pderakhshanfar.codecocoonplugin.suggestions.SuggestionsApi import com.github.pderakhshanfar.codecocoonplugin.transformation.require import com.intellij.openapi.application.ApplicationManager @@ -64,6 +65,7 @@ class MoveFileIntoSuggestedDirectoryTransformation private constructor( override fun apply( psiFile: PsiFile, virtualFile: VirtualFile, + memory: Memory? ): TransformationResult { // validate that this is a Java file if (psiFile !is PsiJavaFile) { diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/RenameClassTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/RenameClassTransformation.kt index b8213966..ee389915 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/RenameClassTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/RenameClassTransformation.kt @@ -8,6 +8,9 @@ import com.github.pderakhshanfar.codecocoonplugin.executor.TransformationResult import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout import com.github.pderakhshanfar.codecocoonplugin.intellij.psi.document import com.github.pderakhshanfar.codecocoonplugin.java.JavaTransformation +import com.github.pderakhshanfar.codecocoonplugin.memory.Memory +import com.github.pderakhshanfar.codecocoonplugin.memory.PsiSignatureGenerator +import com.github.pderakhshanfar.codecocoonplugin.transformation.requireOrDefault import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.readAction import com.intellij.openapi.diagnostic.thisLogger @@ -34,9 +37,13 @@ class RenameClassTransformation( private val logger = thisLogger().withStdout() override fun apply( - psiFile: PsiFile, virtualFile: VirtualFile + psiFile: PsiFile, + virtualFile: VirtualFile, + memory: Memory? ): TransformationResult { val result = try { + val useMemory = config.requireOrDefault("useMemory", defaultValue = false) + val document = withReadAction { psiFile.document() } val modifiedFiles = mutableSetOf() val value = if (document != null) { @@ -48,19 +55,32 @@ class RenameClassTransformation( logger.info(" ⏲ Generating rename suggestions for ${eligibleClasses.size} classes...") - val renameSuggestions = runBlocking { - eligibleClasses.associateWith { psiClass -> getNewClassNames(psiClass) } + val renameSuggestions = if (useMemory) { + extractRenamesFromMemory(eligibleClasses, memory) + } else { + runBlocking { generateRenames(eligibleClasses) } } val successfulRenames = eligibleClasses.mapNotNull { psiClass -> val className = withReadAction { psiClass.name } val suggestions = renameSuggestions[psiClass] ?: return@mapNotNull null + // Generate signature before renaming + val signature = withReadAction { PsiSignatureGenerator.generateSignature(psiClass) } + if (signature == null) { + logger.warn(" ⊘ Could not generate signature for class $className") + return@mapNotNull null + } + // Try each suggestion until one succeeds for (suggestion in suggestions) { val files = tryRenameClassAndUsages(psiFile.project, psiClass, suggestion) if (files != null) { modifiedFiles.addAll(files) + if (!useMemory) { + memory?.put(signature, suggestion) + logger.info(" ✓ Stored rename in memory: `$signature` -> `$suggestion`") + } return@mapNotNull psiClass to suggestion } } @@ -72,6 +92,7 @@ class RenameClassTransformation( val renamedCount = successfulRenames.size val totalCandidates = eligibleClasses.size val skipped = totalCandidates - renamedCount + TransformationResult.Success( message = "Renamed ${renamedCount}/${totalCandidates} classes in ${virtualFile.name}${if (skipped > 0) " (skipped: $skipped)" else ""}", filesModified = modifiedFiles.size @@ -98,7 +119,41 @@ class RenameClassTransformation( val fieldNames: List ) - private suspend fun getNewClassNames(psiClass: PsiClass, count: Int = DEFAULT_SUGGESTED_NAMES_SIZE): List { + /** + * Extracts rename suggestions from memory for classes. + */ + private fun extractRenamesFromMemory( + classes: List, + memory: Memory? + ): Map> { + return classes.associateWith { psiClass -> + val signature = withReadAction { PsiSignatureGenerator.generateSignature(psiClass) } + if (signature == null) { + logger.warn("Could not generate signature for class") + return@associateWith emptyList() + } + + val cachedName = memory?.get(signature) + if (cachedName != null) { + logger.info(" ↳ Using cached rename: $signature -> $cachedName") + listOf(cachedName) + } else { + logger.warn(" ⊘ Signature not found in memory: $signature") + emptyList() + } + } + } + + /** + * Generates rename suggestions for all classes using LLM. + */ + private suspend fun generateRenames(classes: List): Map> { + return classes.associateWith { psiClass -> + generateNewClassNames(psiClass) + } + } + + private suspend fun generateNewClassNames(psiClass: PsiClass, count: Int = DEFAULT_SUGGESTED_NAMES_SIZE): List { val context = readAction { val type = when { psiClass.isInterface -> "interface" diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/RenameMethodTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/RenameMethodTransformation.kt index 0db86736..38da87d0 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/RenameMethodTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/RenameMethodTransformation.kt @@ -8,6 +8,9 @@ import com.github.pderakhshanfar.codecocoonplugin.executor.TransformationResult import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout import com.github.pderakhshanfar.codecocoonplugin.intellij.psi.document import com.github.pderakhshanfar.codecocoonplugin.java.JavaTransformation +import com.github.pderakhshanfar.codecocoonplugin.memory.Memory +import com.github.pderakhshanfar.codecocoonplugin.memory.PsiSignatureGenerator +import com.github.pderakhshanfar.codecocoonplugin.transformation.requireOrDefault import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.readAction import com.intellij.openapi.diagnostic.thisLogger @@ -36,9 +39,13 @@ class RenameMethodTransformation( } override fun apply( - psiFile: PsiFile, virtualFile: VirtualFile + psiFile: PsiFile, + virtualFile: VirtualFile, + memory: Memory? ): TransformationResult { val result = try { + val useMemory = config.requireOrDefault("useMemory", defaultValue = false) + val document = IntelliJAwareTransformation.withReadAction { psiFile.document() } val modifiedFiles = mutableSetOf() val value = if (document != null) { @@ -52,8 +59,10 @@ class RenameMethodTransformation( logger.info(" ⏲ Generating rename suggestions for ${publicMethods.size} methods...") - val renameSuggestions = runBlocking { - publicMethods.associateWith { method -> getNewMethodNames(method) } + val renameSuggestions = if (useMemory) { + extractRenamesFromMemory(publicMethods, memory) + } else { + runBlocking { generateRenames(publicMethods) } } // Try renaming each method with suggestions until one succeeds @@ -61,11 +70,24 @@ class RenameMethodTransformation( val methodName = IntelliJAwareTransformation.withReadAction { method.name } val suggestions = renameSuggestions[method] ?: return@mapNotNull null + // Generate signature BEFORE renaming + val signature = IntelliJAwareTransformation.withReadAction { + PsiSignatureGenerator.generateSignature(method) + } + if (signature == null) { + logger.warn(" ⊘ Could not generate signature for method $methodName") + return@mapNotNull null + } + // Try each suggestion until one succeeds (no conflicts) for (suggestion in suggestions) { val files = tryRenameMethodAndUsages(psiFile.project, method, suggestion) if (files != null) { modifiedFiles.addAll(files) + if (!useMemory) { + memory?.put(signature, suggestion) + logger.info(" ✓ Stored rename in memory: `$signature` -> `$suggestion`") + } return@mapNotNull method to suggestion } } @@ -77,6 +99,7 @@ class RenameMethodTransformation( val renamedCount = successfulRenames.size val totalCandidates = publicMethods.size val skipped = totalCandidates - renamedCount + TransformationResult.Success( message = "Renamed ${renamedCount}/${totalCandidates} methods in ${virtualFile.name}${if (skipped > 0) " (skipped: $skipped)" else ""}", filesModified = modifiedFiles.size @@ -102,7 +125,43 @@ class RenameMethodTransformation( val className: String? ) - private suspend fun getNewMethodNames(method: PsiMethod, count: Int = DEFAULT_SUGGESTED_NAMES_SIZE): List { + /** + * Extracts rename suggestions from memory for methods. + */ + private fun extractRenamesFromMemory( + methods: List, + memory: Memory? + ): Map> { + return methods.associateWith { method -> + val signature = IntelliJAwareTransformation.withReadAction { + PsiSignatureGenerator.generateSignature(method) + } + if (signature == null) { + logger.warn("Could not generate signature for method") + return@associateWith emptyList() + } + + val cachedName = memory?.get(signature) + if (cachedName != null) { + logger.info(" ↳ Using cached rename: $signature -> $cachedName") + listOf(cachedName) + } else { + logger.info(" ⊘ Signature not found in memory: $signature") + emptyList() + } + } + } + + /** + * Generates rename suggestions for all methods using LLM. + */ + private suspend fun generateRenames(methods: List): Map> { + return methods.associateWith { method -> + generateNewMethodNames(method) + } + } + + private suspend fun generateNewMethodNames(method: PsiMethod, count: Int = DEFAULT_SUGGESTED_NAMES_SIZE): List { // Extract all PSI data in a read action before building the prompt val context = readAction { MethodContext( diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/RenameVariableTransformation.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/RenameVariableTransformation.kt index 3bf0159a..5d37872f 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/RenameVariableTransformation.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/components/transformations/RenameVariableTransformation.kt @@ -8,6 +8,9 @@ import com.github.pderakhshanfar.codecocoonplugin.executor.TransformationResult import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout import com.github.pderakhshanfar.codecocoonplugin.intellij.psi.document import com.github.pderakhshanfar.codecocoonplugin.java.JavaTransformation +import com.github.pderakhshanfar.codecocoonplugin.memory.Memory +import com.github.pderakhshanfar.codecocoonplugin.memory.PsiSignatureGenerator +import com.github.pderakhshanfar.codecocoonplugin.transformation.requireOrDefault import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.readAction import com.intellij.openapi.diagnostic.thisLogger @@ -34,9 +37,13 @@ class RenameVariableTransformation( private val logger = thisLogger().withStdout() override fun apply( - psiFile: PsiFile, virtualFile: VirtualFile + psiFile: PsiFile, + virtualFile: VirtualFile, + memory: Memory? ): TransformationResult { val result = try { + val useMemory = config.requireOrDefault("useMemory", defaultValue = false) + val document = withReadAction { psiFile.document() } val modifiedFiles = mutableSetOf() val value = if (document != null) { @@ -48,42 +55,45 @@ class RenameVariableTransformation( logger.info(" ⏲ Generating rename suggestions for ${eligibleVariables.size} variables...") - val renameSuggestions = runBlocking { - getAllVariableRenameSuggestions(eligibleVariables) + val renameSuggestions = if (useMemory) { + extractRenamesFromMemory(eligibleVariables, memory) + } else { + runBlocking { generateRenames(eligibleVariables) } } - // Create a map from variable name to suggestions for easy lookup - val suggestionMap = renameSuggestions.associateBy { it.originalName } - // Try renaming each variable with suggestions until one succeeds val successfulRenames = eligibleVariables.mapNotNull { psiVar -> - val (varName, validSuggestions) = withReadAction { - val name = psiVar.name ?: return@withReadAction null - val suggestions = suggestionMap[name]?.suggestions ?: return@withReadAction null - val nameHelper = PsiNameHelper.getInstance(psiFile.project) - val validated = buildSuggestionList(suggestions, psiFile.project) - .filter { nameHelper.isIdentifier(it) } - - if (validated.isEmpty()) return@withReadAction null - name to validated - } ?: return@mapNotNull null + val varName = withReadAction { psiVar.name } + val suggestions = renameSuggestions[psiVar] ?: return@mapNotNull null + + // Generate signature BEFORE renaming + val signature = withReadAction { PsiSignatureGenerator.generateSignature(psiVar) } + if (signature == null) { + logger.warn(" ⊘ Could not generate signature for variable $varName") + return@mapNotNull null + } // Try each suggestion until one succeeds (no conflicts) - for (suggestion in validSuggestions) { + for (suggestion in suggestions) { val files = tryRenameVariableAndUsages(psiFile.project, psiVar, suggestion) if (files != null) { modifiedFiles.addAll(files) + if (!useMemory) { + memory?.put(signature, suggestion) + logger.info(" ✓ Stored rename in memory: `$signature` -> `$suggestion`") + } return@mapNotNull psiVar to suggestion } } // No valid suggestion worked - logger.info(" ⊘ Skipped renaming variable $varName, with suggestions: ${suggestionMap[varName]?.suggestions}") + logger.info(" ⊘ Skipped renaming variable $varName, with suggestions: $suggestions") null } val renamedCount = successfulRenames.size val totalCandidates = eligibleVariables.size val skipped = totalCandidates - renamedCount + TransformationResult.Success( message = "Renamed ${renamedCount}/${totalCandidates} variables in ${virtualFile.name}${if (skipped > 0) " (skipped: $skipped)" else ""}", filesModified = modifiedFiles.size @@ -100,6 +110,46 @@ class RenameVariableTransformation( return result } + /** + * Extracts rename suggestions from memory for variables. + */ + private fun extractRenamesFromMemory( + variables: List, + memory: Memory? + ): Map> { + return variables.associateWith { psiVar -> + val signature = withReadAction { PsiSignatureGenerator.generateSignature(psiVar) } + if (signature == null) { + logger.warn("Could not generate signature for variable") + return@associateWith emptyList() + } + + val cachedName = memory?.get(signature) + if (cachedName != null) { + logger.info(" ↳ Using cached rename: $signature -> $cachedName") + listOf(cachedName) + } else { + logger.info(" ⊘ Signature not found in memory: $signature") + emptyList() + } + } + } + + /** + * Generates rename suggestions for all variables using LLM. + * Uses a single batch LLM call for efficiency. + */ + private suspend fun generateRenames(variables: List): Map> { + val batchRenamings = generateNewVariableNames(variables) + return variables.associateWith { psiVar -> + val varName = withReadAction { psiVar.name } + val renaming = batchRenamings.find { it.originalName == varName } + renaming?.suggestions?.let { + withReadAction { buildSuggestionList(it, psiVar.project) } + } ?: emptyList() + } + } + @Serializable private data class VariableRenaming( val originalName: String, @@ -175,7 +225,7 @@ class RenameVariableTransformation( ) } - private suspend fun getAllVariableRenameSuggestions( + private suspend fun generateNewVariableNames( variables: List, count: Int = DEFAULT_SUGGESTED_NAMES_SIZE ): List { @@ -224,7 +274,7 @@ class RenameVariableTransformation( project: Project, psiVariable: PsiVariable, newName: String ): MutableSet? { return try { - val oldName = psiVariable.name ?: return null + val oldName = withReadAction { psiVariable.name } ?: return null // isSearchInComments needs to be false. If true, it would breaks functionality by changing string literals. // example would be mappings of `PathVariable` from Spring. // `@param [paramName]` definitions in the Javadocs are still being renamed. diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/config/CodeCocoonConfig.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/config/CodeCocoonConfig.kt index 18bd39b0..860637ad 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/config/CodeCocoonConfig.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/config/CodeCocoonConfig.kt @@ -10,6 +10,8 @@ package com.github.pderakhshanfar.codecocoonplugin.config val files: List = emptyList(), /** Ordered list of transformations to execute */ val transformations: List = emptyList(), + /** Directory where memory files are stored (resolved to absolute path by ConfigLoader) */ + val memoryDir: String, ) /** diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/config/ConfigLoader.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/config/ConfigLoader.kt index f71dfbbf..f9787bdc 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/config/ConfigLoader.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/config/ConfigLoader.kt @@ -47,11 +47,49 @@ object ConfigLoader { TransformationConfig(id = id, config = cfg) } + // Resolve memory directory + val memoryDir = resolveMemoryDir(root["memoryDir"]?.toString()) + return CodeCocoonConfig( projectRoot = projectRoot, files = files, transformations = transformations, + memoryDir = memoryDir, ) } } + + /** + * Resolves the memory directory to an absolute path string. + * + * If [memoryDirPath] is provided: + * - If absolute: use as-is + * - If relative: resolve relative to config file's parent directory + * + * If [memoryDirPath] is null: + * - Default to ".codecocoon-memory" in config file's parent directory + * + * @param memoryDirPath Optional memory directory path from YAML + * @return Resolved absolute path for memory directory + */ + private fun resolveMemoryDir(memoryDirPath: String?): String { + val configPath = System.getProperty("codecocoon.config") + ?: throw IllegalStateException("codecocoon.config system property not set") + + val configFile = File(configPath) + val configParentDir = configFile.parentFile + ?: throw IllegalStateException("Config file has no parent directory: $configPath") + + return if (memoryDirPath != null) { + val memoryFile = File(memoryDirPath) + if (memoryFile.isAbsolute) { + memoryFile.canonicalPath + } else { + File(configParentDir, memoryDirPath).canonicalPath + } + } else { + // Default to .codecocoon-memory in config parent directory + File(configParentDir, ".codecocoon-memory").canonicalPath + } + } } \ No newline at end of file diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/intellij/Project.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/intellij/Project.kt index 01d184d9..348edb74 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/intellij/Project.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/intellij/Project.kt @@ -1,6 +1,7 @@ package com.github.pderakhshanfar.codecocoonplugin.intellij import com.github.pderakhshanfar.codecocoonplugin.common.ProjectConfiguratorFailed +import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout import com.github.pderakhshanfar.codecocoonplugin.intellij.vfs.refreshAndFindVirtualFile import com.intellij.conversion.ConversionListener import com.intellij.conversion.ConversionService @@ -13,7 +14,9 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState import com.intellij.openapi.application.ex.ApplicationManagerEx +import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.progress.util.ProgressIndicatorBase +import com.intellij.openapi.project.DumbService import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.vfs.VirtualFile @@ -21,7 +24,6 @@ import com.intellij.openapi.vfs.VirtualFileManager import org.jetbrains.idea.maven.MavenCommandLineInspectionProjectConfigurator import org.jetbrains.idea.maven.project.MavenProjectsManager import org.jetbrains.plugins.gradle.GradleCommandLineProjectConfigurator -import org.slf4j.LoggerFactory import java.nio.file.Path import java.util.function.Predicate @@ -46,7 +48,7 @@ class JvmProjectConfigurator { } private class JvmProjectResolver { - private val logger = LoggerFactory.getLogger(javaClass) + private val logger = thisLogger().withStdout() fun resolveProject(project: Project) { logger.info("Started to resolve project ${project.name}.") @@ -70,7 +72,7 @@ private class JvmProjectResolver { } private object ProjectApplicationUtils { - private val logger = LoggerFactory.getLogger(javaClass) + private val logger = thisLogger().withStdout() /** * Rewritten from [com.intellij.codeInspection.InspectionApplicationBase]. @@ -145,6 +147,64 @@ private object ProjectApplicationUtils { configurator.preConfigureProject(project, context) configurator.configureProject(project, context) waitForInvokeLaterActivities() + + // Manual Maven import trigger + if (configurator is MavenCommandLineInspectionProjectConfigurator) { + val mavenManager = MavenProjectsManager.getInstance(project) + logger.info("Triggering Maven import... (current modules: ${mavenManager.projects.size})") + + ApplicationManager.getApplication().invokeAndWait { + mavenManager.forceUpdateAllProjectsOrFindAllAvailablePomFiles() + } + + // Wait for Maven modules to appear - stable count for 3 seconds + var attempts = 0 + var lastCount = 0 + var stableCount = 0 + + while (attempts < 120) { + waitForInvokeLaterActivities() + val count = mavenManager.projects.size + + if (count == lastCount && count > 0) { + stableCount++ + if (stableCount >= 3) { + logger.info("Maven import completed. Found ${count} modules") + break + } + } else { + stableCount = 0 + lastCount = count + } + + Thread.sleep(1000) + attempts++ + } + + if (mavenManager.projects.size == 0) { + logger.warn("Maven import may have failed - no modules found") + } + + // Wait for indexing to complete + logger.info("Waiting for indexing to complete...") + val dumbService = DumbService.getInstance(project) + var indexAttempts = 0 + while (dumbService.isDumb && indexAttempts < 120) { + Thread.sleep(1000) + indexAttempts++ + } + if (dumbService.isDumb) { + logger.warn("Indexing did not complete in time") + } else { + logger.info("Indexing completed") + } + + // Extra buffer for source roots to register + logger.info("Waiting for source roots to be registered...") + Thread.sleep(5000) + logger.info("Project resolution complete") + } + logger.info("Project ${project.name} was successfully resolved with configurator ${configurator.name}!") } @@ -173,7 +233,7 @@ private object ProjectApplicationUtils { } private class ConversionListenerImpl : ConversionListener { - private val logger = LoggerFactory.getLogger(javaClass) + private val logger = thisLogger().withStdout() override fun conversionNeeded() { logger.info("Conversion is needed for project.") @@ -198,7 +258,7 @@ private class ConfiguratorContextImpl( private val filesFilter: Predicate = Predicate { true }, private val virtualFilesFilter: Predicate = Predicate { true }, ) : ConfiguratorContext { - private val logger = LoggerFactory.getLogger(javaClass) + private val logger = thisLogger().withStdout() override fun getProgressIndicator() = indicator override fun getLogger() = object : CommandLineInspectionProgressReporter { override fun reportError(message: String) { diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/PersistentMemory.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/PersistentMemory.kt new file mode 100644 index 00000000..6688d86f --- /dev/null +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/PersistentMemory.kt @@ -0,0 +1,148 @@ +package com.github.pderakhshanfar.codecocoonplugin.memory + +import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout +import com.intellij.openapi.diagnostic.thisLogger +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.nio.file.Path +import kotlin.io.path.* + +/** + * File-based persistent storage implementation of [Memory] interface. + * + * Stores key-value pairs as JSON in a file within the specified directory. + * Files are organized by project name to allow tracking multiple projects independently. + * + * **Storage Model:** + * Each project gets its own JSON file containing all key-value pairs for that project. + * For example, given `memoryDirPath = "/path/to/memory"` and three projects: + * - `PersistentMemory("project-A", memoryDirPath)` → `/path/to/memory/project-A.json` + * - `PersistentMemory("project-B", memoryDirPath)` → `/path/to/memory/project-B.json` + * - `PersistentMemory("nested/project-C", memoryDirPath)` → `/path/to/memory/nested_project-C.json` (sanitized) + * + * Each JSON file contains all entries for that project, persisted on [save] or [close]. + * + * **Thread Safety:** This implementation is not thread-safe. Use external synchronization + * if accessing from multiple threads. + * + * **Usage:** + * ```kotlin + * PersistentMemory("myProject", "/path/to/memory").use { memory -> + * memory.put("key", "value") + * memory.get("key") // returns "value" + * } // automatically saves on close + * ``` + * + * @param projectName The name of the project (used for the memory filename) + * @param memoryDirPath The directory path where memory files should be stored + */ +class PersistentMemory(private val projectName: String, memoryDirPath: String) : Memory { + + private val logger = thisLogger().withStdout() + + private val memoryFile: Path = run { + // Sanitize project name for use in filename + val sanitizedName = sanitizeProjectName(projectName) + + // Convert path to Path and ensure memory directory exists + val memoryDir = Path(memoryDirPath) + memoryDir.createDirectories() + + memoryDir.resolve("$sanitizedName.json") + } + + private var state: MemoryState = loadFromDisk(from = memoryFile) + + override fun get(key: String): String? { + if (key.isBlank()) { + logger.warn("Attempted to get empty key from memory") + return null + } + return state.entries[key] + } + + override fun put(key: String, value: String): String? { + if (key.isBlank()) { + logger.warn("Attempted to store empty key in memory") + return null + } + if (value.isBlank()) { + logger.warn("Attempted to store empty value for key: $key") + return null + } + + return state.entries.put(key, value) + } + + override fun save() { + val jsonString = json.encodeToString(state) + memoryFile.writeText(jsonString) + logger.info(" ↳ Successfully saved memory for project '$projectName' (${state.entries.size} entries)") + } + + override fun size(): Int = state.entries.size + + /** + * Loads memory data from disk, or creates a new empty memory if the file doesn't exist. + * Throws on JSON parse errors or project name mismatches. + * + * @param from The path to the memory file to load from + */ + private fun loadFromDisk(from: Path): MemoryState { + if (!from.exists()) { + logger.info(" • No existing memory file found for project '$projectName', creating new memory") + return MemoryState(projectName, mutableMapOf()) + } + + val jsonString = from.readText() + val loaded = json.decodeFromString(jsonString) + + // Verify project name matches + if (loaded.projectName != projectName) { + throw IllegalStateException( + "Memory file project name mismatch: expected '$projectName', found '${loaded.projectName}'. " + + "Memory file: ${from.absolutePathString()}" + ) + } + + return loaded + } + + /** + * Sanitizes a project name to be safe for use in a filename. + * Throws if the project name is blank or becomes blank after sanitization. + */ + private fun sanitizeProjectName(name: String): String { + val sanitized = name + .replace(Regex("[^a-zA-Z0-9_-]"), "_") + .take(100) // Limit length to avoid filesystem issues + + if (sanitized.isBlank()) { + throw IllegalArgumentException( + "Project name '$name' contains only invalid characters or is blank." + ) + } + + return sanitized + } + + companion object { + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + } + } +} + +/** + * Data class representing the persistent memory file structure. + * + * @property projectName The name of the project this memory belongs to + * @property entries Map from key to value + */ +@Serializable +private data class MemoryState( + val projectName: String, + val entries: MutableMap +) diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/PsiSignatureGenerator.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/PsiSignatureGenerator.kt new file mode 100644 index 00000000..cabbdd97 --- /dev/null +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/memory/PsiSignatureGenerator.kt @@ -0,0 +1,94 @@ +package com.github.pderakhshanfar.codecocoonplugin.memory + +import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.psi.* +import com.intellij.psi.util.PsiTreeUtil + +/** + * Generates unique signatures for PSI elements to enable deterministic tracking. + */ +object PsiSignatureGenerator { + + private val logger = thisLogger().withStdout() + /** + * Generates a unique signature for the given PSI element. + * + * @param psiElement The PSI element to generate a signature for + * @return A unique signature string, or null if the element type is not supported + * or if the signature cannot be generated. + */ + fun generateSignature(psiElement: PsiElement): String? { + return try { + when (psiElement) { + is PsiClass -> psiElement.generateSignature() + is PsiField -> psiElement.generateSignature() + is PsiMethod -> psiElement.generateSignature() + is PsiParameter -> psiElement.generateSignature() + is PsiLocalVariable -> psiElement.generateSignature() + else -> null + } + } catch (e: Exception) { + logger.warn("Error in generating signature for PsiElement (${e.message})") + null + } + } +} + +/** + * Generates signature for a PsiClass. + * Format: package.ClassName + */ +private fun PsiClass.generateSignature(): String? { + return qualifiedName +} + +/** + * Generates signature for a PsiField. + * Format: fully.qualified.ClassName#fieldName + */ +private fun PsiField.generateSignature(): String? { + val classFqn = containingClass?.qualifiedName ?: return null + val fieldName = name + return "$classFqn#$fieldName" +} + +/** + * Generates signature for a PsiMethod. + * Format: fully.qualified.ClassName#methodName(param.Type1,param.Type2) + * + * Uses fully qualified names for all types for consistency and simplicity. + */ +private fun PsiMethod.generateSignature(): String? { + val classFqn = containingClass?.qualifiedName ?: return null + val methodName = name + + // Build parameter list using canonical (fully qualified) type names + val paramTypes = parameterList.parameters.joinToString(", ") { param -> + param.type.canonicalText + } + + return "$classFqn#$methodName($paramTypes)" +} + +/** + * Generates signature for a PsiParameter. + * Format: fully.qualified.ClassName#methodName(param.Type1,param.Type2)#param:parameterName + */ +private fun PsiParameter.generateSignature(): String? { + val containingMethod = PsiTreeUtil.getParentOfType(this, PsiMethod::class.java) ?: return null + val methodSignature = containingMethod.generateSignature() ?: return null + val paramName = name + return "$methodSignature#param:$paramName" +} + +/** + * Generates signature for a PsiLocalVariable. + * Format: fully.qualified.ClassName#methodName(param.Type1,param.Type2)#localVar:variableName + */ +private fun PsiLocalVariable.generateSignature(): String? { + val containingMethod = PsiTreeUtil.getParentOfType(this, PsiMethod::class.java) ?: return null + val methodSignature = containingMethod.generateSignature() ?: return null + val varName = name + return "$methodSignature#localVar:$varName" +} diff --git a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt index 846da3b0..0ffd6cfd 100644 --- a/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt +++ b/src/main/kotlin/com/github/pderakhshanfar/codecocoonplugin/services/TransformationService.kt @@ -6,6 +6,7 @@ import com.github.pderakhshanfar.codecocoonplugin.components.executor.IntelliJTr import com.github.pderakhshanfar.codecocoonplugin.config.CodeCocoonConfig import com.github.pderakhshanfar.codecocoonplugin.executor.TransformationResult import com.github.pderakhshanfar.codecocoonplugin.intellij.logging.withStdout +import com.github.pderakhshanfar.codecocoonplugin.memory.PersistentMemory import com.github.pderakhshanfar.codecocoonplugin.transformation.Transformation import com.intellij.openapi.application.smartReadAction import com.intellij.openapi.components.Service @@ -16,6 +17,7 @@ import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VfsUtilCore import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.VirtualFileVisitor +import java.io.File /** * Application-level service responsible for managing metamorphic transformations @@ -44,7 +46,6 @@ class TransformationService { ) { logger.info("[TransformationService] Starting transformation pipeline for project: ${project.name}") - // TODO: remove in the future // Step 1: List project files according to config val files = listProjectFiles(project, config.projectRoot, includeOnly = config.files) // Step 2: Print files to the console @@ -87,7 +88,7 @@ class TransformationService { if (!file.isDirectory) { // Get path relative to the resolved project root val relativePath = VfsUtilCore.getRelativePath(file, projectRoot) - if (relativePath != null && relativePath.startsWith("src")) { + if (relativePath != null && relativePath.contains("src/main/")) { if (includeSet.isEmpty()) { add(relativePath) } else if (relativePath in includeSet @@ -127,41 +128,48 @@ class TransformationService { val files = listProjectFiles(project, config.projectRoot, includeOnly = config.files) val executor = IntelliJTransformationExecutor(project) - var successCount = 0 - var failureCount = 0 - var skippedCount = 0 + // Create global memory instance for the entire project + // Memory is automatically saved via .use {} when block exits + val projectName = project.basePath?.let { File(it).name } ?: project.name + PersistentMemory(projectName, config.memoryDir).use { memory -> + logger.info("[TransformationService] Created global memory for project '$projectName'") - for (filePath in files) { - val context = createFileContext(filePath) + var successCount = 0 + var failureCount = 0 + var skippedCount = 0 - if (!fileFilter(context)) { - skippedCount++ - continue - } + for (filePath in files) { + val context = createFileContext(filePath) - for (transformation in transformations) { - if (transformation.accepts(context)) { - logger.info("Applying ${transformation.id} to $filePath") + if (!fileFilter(context)) { + skippedCount++ + continue + } - when (val result = executor.execute(transformation, context)) { - is TransformationResult.Success -> { - logger.info("✓ ${result.message}") - successCount++ - } - is TransformationResult.Failure -> { - logger.error("✗ ${result.error}", result.exception) - failureCount++ - } - is TransformationResult.Skipped -> { - logger.info("⊘ Skipped: ${result.reason}") - skippedCount++ + for (transformation in transformations) { + if (transformation.accepts(context)) { + logger.info("Applying ${transformation.id} to $filePath") + + when (val result = executor.execute(transformation, context, memory)) { + is TransformationResult.Success -> { + logger.info(" ✓ ${result.message}") + successCount++ + } + is TransformationResult.Failure -> { + logger.error(" ✗ ${result.error}", result.exception) + failureCount++ + } + is TransformationResult.Skipped -> { + logger.info(" ⊘ Skipped: ${result.reason}") + skippedCount++ + } } } } } - } - logger.info("[TransformationService] Transformation summary: $successCount succeeded, $failureCount failed, $skippedCount skipped") + logger.info("[TransformationService] Transformation summary: $successCount succeeded, $failureCount failed, $skippedCount skipped") + } } private fun createFileContext(relativePath: String): FileContext {