From d825b7a6373c5830642f70efffec6ad89bf5632c Mon Sep 17 00:00:00 2001 From: Dmitri Sh Date: Sun, 28 Dec 2025 19:44:11 -0500 Subject: [PATCH] refine counting and navigation to source code --- .../ResourceUsageLineMarkerProvider.kt | 282 +++++++++++++++--- .../UsageCounter.kt | 264 +++++++++++----- 2 files changed, 429 insertions(+), 117 deletions(-) diff --git a/src/main/kotlin/com/coroutines/androidresourceusagetracker/ResourceUsageLineMarkerProvider.kt b/src/main/kotlin/com/coroutines/androidresourceusagetracker/ResourceUsageLineMarkerProvider.kt index 8cf6101..89eb4b9 100644 --- a/src/main/kotlin/com/coroutines/androidresourceusagetracker/ResourceUsageLineMarkerProvider.kt +++ b/src/main/kotlin/com/coroutines/androidresourceusagetracker/ResourceUsageLineMarkerProvider.kt @@ -3,77 +3,261 @@ package com.coroutines.androidresourceusagetracker import com.intellij.codeInsight.daemon.LineMarkerInfo import com.intellij.codeInsight.daemon.LineMarkerProvider import com.intellij.openapi.editor.markup.GutterIconRenderer +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.psi.PsiElement import com.intellij.psi.xml.XmlTag +import com.intellij.ui.Gray +import com.intellij.ui.JBColor +import java.awt.* +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.geom.RoundRectangle2D +import javax.swing.* +import javax.swing.border.EmptyBorder class ResourceUsageLineMarkerProvider : LineMarkerProvider { + /* override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? { - return null + if (element !is XmlTag) return null + if (element.name !in listOf("string", "color", "dimen", "style", "drawable", "integer", "bool", "array", "string-array", "integer-array", "plurals", "id")) { + return null + } + + val resourceName = element.getAttributeValue("name") ?: return null + val count = UsageCounter.countUsages(element) + + return LineMarkerInfo( + element, + element.textRange, + createUsageIcon(count), + { "$count usage${if (count != 1) "s" else ""}" }, + { e, elt -> + if (count > 0) { + showUsagesPopup(e, elt as XmlTag, elt.project) + } + }, + GutterIconRenderer.Alignment.RIGHT, + { "$count usage${if (count != 1) "s" else ""}" } + ) } - override fun collectSlowLineMarkers( - elements: List, - result: MutableCollection> - ) { - for (element in elements) { - if (element !is XmlTag) continue - if (!isAndroidResourceTag(element)) continue - - val resourceName = element.getAttributeValue("name") ?: continue - val usageCount = UsageCounter.countUsages(element) - - val icon = ResourceUsageIconGenerator.createIcon(usageCount) - val anchorElement = element.firstChild ?: continue - - val lineMarker = LineMarkerInfo( - anchorElement, - element.textRange, - icon, - { createTooltip(resourceName, usageCount, element) }, - null, - GutterIconRenderer.Alignment.RIGHT - ) + */ + + override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? { + // Only process the tag name identifier (leaf element), not the whole tag + if (element !is com.intellij.psi.xml.XmlToken) return null + if (element.tokenType != com.intellij.psi.xml.XmlTokenType.XML_NAME) return null - result.add(lineMarker) + // Make sure this is the opening tag name, not closing tag or attribute name + // The previous sibling should be XML_START_TAG_START ("<") + val prevSibling = element.prevSibling + if (prevSibling !is com.intellij.psi.xml.XmlToken || + prevSibling.tokenType != com.intellij.psi.xml.XmlTokenType.XML_START_TAG_START) { + return null } + + val parent = element.parent + if (parent !is XmlTag) return null + + // Check if this is a resource tag we care about + if (parent.name !in listOf("string", "color", "dimen", "style", "drawable", "integer", "bool", "array", "string-array", "integer-array", "plurals", "id")) { + return null + } + + // Only process if this tag has a "name" attribute (it's a resource definition) + val resourceName = parent.getAttributeValue("name") ?: return null + + // Make sure we're in a values XML file (check parent directory name) + val parentDirName = element.containingFile.virtualFile?.parent?.name ?: "" + if (!parentDirName.contains("values")) { + return null + } + + val count = UsageCounter.countUsages(parent) + + return LineMarkerInfo( + element, // Register on the leaf element (XML_NAME token) + element.textRange, + createUsageIcon(count), + { "$count usage${if (count != 1) "s" else ""}" }, + { e, elt -> + if (count > 0) { + // Navigate up to the XmlTag for processing + val tag = elt.parent as? XmlTag ?: return@LineMarkerInfo + showUsagesPopup(e, tag, tag.project) + } + }, + GutterIconRenderer.Alignment.RIGHT, + { "$count usage${if (count != 1) "s" else ""}" } + ) } + private fun createUsageIcon(count: Int): Icon { + return object : Icon { + override fun getIconWidth() = 21 + override fun getIconHeight() = 21 + + override fun paintIcon(c: Component?, g: Graphics?, x: Int, y: Int) { + val g2d = g as Graphics2D + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + + val color = when { + count == 0 -> JBColor(Color(200, 200, 200), Gray._100) + count < 5 -> JBColor(Color(100, 180, 255), Color(80, 140, 200)) + else -> JBColor(Color(100, 200, 100), Color(80, 160, 80)) + } + + g2d.color = color + g2d.fill(RoundRectangle2D.Double(x.toDouble(), y.toDouble(), 20.0, 20.0, 6.0, 6.0)) - private fun createTooltip(resourceName: String, usageCount: Int, element: XmlTag): String { - if (usageCount == 0) { - return "$resourceName: Not used" + g2d.color = JBColor.WHITE + g2d.font = Font("SansSerif", Font.BOLD, 11) + val text = if (count > 99) "99+" else count.toString() + val fm = g2d.fontMetrics + val textWidth = fm.stringWidth(text) + val textX = x + (20 - textWidth) / 2 + val textY = y + ((20 - fm.height) / 2) + fm.ascent + g2d.drawString(text, textX, textY) + } } + } + private fun showUsagesPopup(event: MouseEvent, element: XmlTag, project: Project) { val usages = UsageCounter.getUsages(element) - val displayCount = if (usageCount > 99) "99+" else usageCount.toString() + if (usages.isEmpty()) return - val tooltip = buildString { - append("") - append("$resourceName: $displayCount usage${if (usageCount != 1) "s" else ""}

") + val popup = JWindow() + popup.type = Window.Type.POPUP - // Show up to 5 usages in tooltip - usages.take(5).forEach { usage -> - append("${usage.filePath}:${usage.lineNumber}
") - append("${usage.codeSnippet}

") - } + val panel = JPanel(BorderLayout()).apply { + border = BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(JBColor.border(), 1), + EmptyBorder(8, 8, 8, 8) + ) + background = JBColor.background() + } - if (usages.size > 5) { - append("...and ${usages.size - 5} more") - } + val titleLabel = JLabel("${usages.size} usage${if (usages.size != 1) "s" else ""}").apply { + font = font.deriveFont(Font.BOLD, 13f) + border = EmptyBorder(0, 0, 8, 0) + } - append("") + panel.add(titleLabel, BorderLayout.NORTH) + + val usagesList = createUsagesList(usages, project) + val scrollPane = JScrollPane(usagesList).apply { + preferredSize = Dimension(600, minOf(300, usages.size * 60 + 20)) + border = null } - return tooltip + panel.add(scrollPane, BorderLayout.CENTER) + + popup.contentPane = panel + popup.pack() + + val locationOnScreen = event.component.locationOnScreen + popup.setLocation(locationOnScreen.x + event.x + 10, locationOnScreen.y + event.y) + + popup.isVisible = true + // popup.isFocusableWindowState = true + popup.requestFocus() + + popup.addWindowFocusListener(object : java.awt.event.WindowFocusListener { + override fun windowGainedFocus(e: java.awt.event.WindowEvent?) {} + override fun windowLostFocus(e: java.awt.event.WindowEvent?) { + popup.dispose() + } + }) } - private fun isAndroidResourceTag(tag: XmlTag): Boolean { - val validTags = setOf( - "string", "color", "dimen", "style", "drawable", - "integer", "bool", "array", "string-array", "integer-array", - "plurals", "attr", "declare-styleable", "item", "id" - ) - return validTags.contains(tag.name) && tag.getAttribute("name") != null + private fun createUsagesList(usages: List, project: Project): JList { + val listModel = DefaultListModel() + usages.forEach { listModel.addElement(it) } + + return JList(listModel).apply { + cellRenderer = UsageCellRenderer() + selectionMode = ListSelectionModel.SINGLE_SELECTION + + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (e.clickCount == 2) { + val usage = selectedValue ?: return + + // Use the full absolute path directly for navigation + val virtualFile = LocalFileSystem.getInstance().findFileByPath(usage.filePath) + + if (virtualFile != null) { + val descriptor = OpenFileDescriptor(project, virtualFile, usage.lineNumber - 1, 0) + descriptor.navigate(true) + } + } + } + }) + } } -} + private class UsageCellRenderer : DefaultListCellRenderer() { + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + val usage = value as ResourceUsage + + val panel = JPanel(BorderLayout()).apply { + border = EmptyBorder(4, 8, 4, 8) + background = if (isSelected) JBColor.background() else JBColor.background() + } + + // Get display-friendly path + val displayPath = getDisplayPath(usage.filePath) + + val fileLabel = JLabel("$displayPath:${usage.lineNumber}").apply { + font = Font("Monospaced", Font.PLAIN, 12) + } + + val codeLabel = JLabel("${escapeHtml(usage.codeSnippet)}").apply { + font = Font("Monospaced", Font.PLAIN, 11) + } + + val labelsPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(fileLabel) + add(Box.createVerticalStrut(2)) + add(codeLabel) + background = if (isSelected) JBColor.background() else JBColor.background() + } + + panel.add(labelsPanel, BorderLayout.CENTER) + + if (isSelected) { + panel.background = JBColor(Color(220, 230, 240), Color(60, 70, 80)) + labelsPanel.background = panel.background + } + + return panel + } + + private fun getDisplayPath(path: String): String { + // Try to get path relative to common source roots for display + return when { + path.contains("/src/main/") -> path.substringAfter("/src/main/") + path.contains("/src/test/") -> path.substringAfter("/src/test/") + path.contains("/src/androidTest/") -> path.substringAfter("/src/androidTest/") + else -> path.substringAfterLast("/") + } + } + + private fun escapeHtml(text: String): String { + return text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coroutines/androidresourceusagetracker/UsageCounter.kt b/src/main/kotlin/com/coroutines/androidresourceusagetracker/UsageCounter.kt index 0a3dc2b..aa9801f 100644 --- a/src/main/kotlin/com/coroutines/androidresourceusagetracker/UsageCounter.kt +++ b/src/main/kotlin/com/coroutines/androidresourceusagetracker/UsageCounter.kt @@ -4,13 +4,16 @@ import com.intellij.openapi.application.ReadAction import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.module.ModuleUtilCore import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.project.Project import com.intellij.psi.PsiElement +import com.intellij.psi.PsiManager import com.intellij.psi.search.GlobalSearchScope import com.intellij.psi.search.PsiSearchHelper import com.intellij.psi.search.TextOccurenceProcessor import com.intellij.psi.search.UsageSearchContext import com.intellij.psi.xml.XmlTag import com.intellij.psi.xml.XmlAttributeValue +import com.intellij.psi.xml.XmlFile data class ResourceUsage( val filePath: String, @@ -35,79 +38,73 @@ object UsageCounter { val project = element.project val definitionFile = element.containingFile - val scope = GlobalSearchScope.moduleScope(module) - val searchHelper = PsiSearchHelper.getInstance(project) - val usages = mutableListOf() val seenLocations = mutableSetOf() - searchHelper.processElementsWithWord( - TextOccurenceProcessor { psiElement, offsetInElement -> - val containingFile = psiElement.containingFile + // Special handling for themes/styles - search manifest files across ALL modules + if (resourceType == "style") { + LOG.info("This is a style resource, searching manifest files...") + searchManifestFilesInProject(project, resourceName, usages, seenLocations) + LOG.info("After manifest search: ${usages.size} usage(s)") + } - // Skip the resource definition file itself - if (containingFile == definitionFile) { - return@TextOccurenceProcessor true - } + // Search across the ENTIRE project, not just this module + val scope = GlobalSearchScope.projectScope(project) + val searchHelper = PsiSearchHelper.getInstance(project) + + // Search for all components of dotted names + val searchWords = mutableListOf() + if (resourceName.contains(".")) { + searchWords.addAll(resourceName.split(".")) + } else { + searchWords.add(resourceName) + } + + LOG.info("Searching for $resourceType/$resourceName using search words: $searchWords") - // Skip generated files - val virtualFile = containingFile.virtualFile - if (virtualFile != null) { - val path = virtualFile.path - - if (path.contains("/build/") || - path.contains("/generated/") || - path.contains("/.gradle/") || - path.contains("/res/values/") || - virtualFile.name == "R.java" || - virtualFile.name.startsWith("BuildConfig")) { + for (searchWord in searchWords) { + searchHelper.processElementsWithWord( + TextOccurenceProcessor { psiElement, offsetInElement -> + val containingFile = psiElement.containingFile + + // Skip the resource definition file itself + if (containingFile == definitionFile) { return@TextOccurenceProcessor true } - // Only count specific PSI element types that represent actual references - if (isLeafReference(psiElement, resourceType, resourceName)) { - // Create unique key: file + line number - val document = containingFile.viewProvider.document - val line = document?.getLineNumber(psiElement.textRange.startOffset) ?: -1 - val key = "$path:$line" - - if (!seenLocations.contains(key)) { - seenLocations.add(key) - - // Extract code snippet - val lineText = if (document != null && line >= 0) { - val lineStart = document.getLineStartOffset(line) - val lineEnd = document.getLineEndOffset(line) - document.getText(com.intellij.openapi.util.TextRange(lineStart, lineEnd)).trim() - } else { - psiElement.text - } - - // Get relative file path - val relativePath = path.substringAfterLast("/app/src/main/", path.substringAfterLast("/")) - - usages.add(ResourceUsage( - filePath = relativePath, - lineNumber = line + 1, // 1-indexed for display - codeSnippet = lineText - )) + // Skip generated files + val virtualFile = containingFile.virtualFile + if (virtualFile != null) { + val path = virtualFile.path + + if (path.contains("/build/") || + path.contains("/generated/") || + path.contains("/.gradle/") || + path.contains("/res/values/") || + virtualFile.name == "R.java" || + virtualFile.name.startsWith("BuildConfig")) { + return@TextOccurenceProcessor true + } + + // Check if this is actually a reference to our resource + if (isResourceReference(psiElement, resourceType, resourceName)) { + addUsage(psiElement, path, containingFile, seenLocations, usages) } } - } - true // continue processing - }, - scope, - resourceName, - UsageSearchContext.ANY.toShort(), - true - ) + true + }, + scope, + searchWord, + UsageSearchContext.ANY.toShort(), + true + ) + } LOG.info("Found ${usages.size} actual usage(s) of $resourceType/$resourceName") return@compute usages } catch (e: ProcessCanceledException) { - // CRITICAL: Rethrow ProcessCanceledException - never catch it! throw e } catch (e: Exception) { LOG.error("Error counting usages", e) @@ -116,25 +113,156 @@ object UsageCounter { } } - /** - * Only count leaf-level reference expressions, not their parent containers - */ - private fun isLeafReference(element: PsiElement, resourceType: String, resourceName: String): Boolean { + private fun searchManifestFilesInProject( + project: Project, + resourceName: String, + usages: MutableList, + seenLocations: MutableSet + ) { + try { + LOG.info("Looking for AndroidManifest.xml across all modules...") + + // Get ALL modules in the project + val moduleManager = com.intellij.openapi.module.ModuleManager.getInstance(project) + val allModules = moduleManager.modules + + LOG.info("Project has ${allModules.size} module(s)") + + for (module in allModules) { + LOG.info(" Checking module: ${module.name}") + val contentRoots = com.intellij.openapi.roots.ModuleRootManager.getInstance(module).contentRoots + + for (contentRoot in contentRoots) { + // Try two locations: + // 1. contentRoot/src/main/AndroidManifest.xml (when content root is app/) + // 2. contentRoot/AndroidManifest.xml (when content root is app/src/main/) + val possiblePaths = listOf( + "src/main/AndroidManifest.xml", + "AndroidManifest.xml" + ) + + for (relativePath in possiblePaths) { + val manifestPath = contentRoot.findFileByRelativePath(relativePath) + if (manifestPath != null && manifestPath.exists()) { + LOG.info(" ✓ Found manifest at: ${manifestPath.path}") + val psiManager = PsiManager.getInstance(project) + val psiFile = psiManager.findFile(manifestPath) + if (psiFile is XmlFile) { + searchXmlRecursively(psiFile.rootTag, "style", resourceName, manifestPath.path, psiFile, seenLocations, usages) + } else { + LOG.info(" Manifest file is not an XmlFile!") + } + break // Found it, no need to check other paths + } + } + } + } + + LOG.info("After searching all modules: ${usages.size} usage(s)") + } catch (e: Exception) { + LOG.error("Error searching manifest files in project", e) + } + } + + private fun searchXmlRecursively( + tag: XmlTag?, + resourceType: String, + resourceName: String, + path: String, + containingFile: com.intellij.psi.PsiFile, + seenLocations: MutableSet, + usages: MutableList + ) { + if (tag == null) return + + LOG.info(" Checking tag: <${tag.name}>") + + // Check all attributes of this tag + for (attribute in tag.attributes) { + val value = attribute.value + LOG.info(" Attribute: ${attribute.name}=\"$value\"") + + if (value == "@$resourceType/$resourceName") { + LOG.info(" ✓ MATCH! Found theme usage: ${attribute.name}=\"$value\"") + addUsage(attribute.valueElement ?: attribute, path, containingFile, seenLocations, usages) + } + } + + // Recursively search child tags + for (childTag in tag.subTags) { + searchXmlRecursively(childTag, resourceType, resourceName, path, containingFile, seenLocations, usages) + } + } + + private fun addUsage( + psiElement: PsiElement, + path: String, + containingFile: com.intellij.psi.PsiFile, + seenLocations: MutableSet, + usages: MutableList + ) { + val document = containingFile.viewProvider.document + val line = document?.getLineNumber(psiElement.textRange.startOffset) ?: -1 + val key = "$path:$line" + + if (!seenLocations.contains(key)) { + seenLocations.add(key) + + // Extract code snippet + val lineText = if (document != null && line >= 0) { + val lineStart = document.getLineStartOffset(line) + val lineEnd = document.getLineEndOffset(line) + document.getText(com.intellij.openapi.util.TextRange(lineStart, lineEnd)).trim() + } else { + psiElement.text + } + + // For logging: show display-friendly path + val displayPath = getDisplayPath(path) + + LOG.info(" -> Added usage at $displayPath:${line + 1}") + + // Store FULL absolute path for navigation + usages.add(ResourceUsage( + filePath = path, // Full absolute path + lineNumber = line + 1, + codeSnippet = lineText + )) + } + } + + private fun getDisplayPath(path: String): String { + // Try to get path relative to common source roots for display + return when { + path.contains("/src/main/") -> path.substringAfter("/src/main/") + path.contains("/src/test/") -> path.substringAfter("/src/test/") + path.contains("/src/androidTest/") -> path.substringAfter("/src/androidTest/") + else -> path.substringAfterLast("/") + } + } + + private fun isResourceReference(element: PsiElement, resourceType: String, resourceName: String): Boolean { val className = element.javaClass.simpleName - val text = element.text - // For XML: only count XmlAttributeValue - if (element is XmlAttributeValue) { - return text == "@$resourceType/$resourceName" + // For XML: check element and its parents for XmlAttributeValue + var current: PsiElement? = element + var depth = 0 + while (current != null && depth < 5) { + if (current is XmlAttributeValue) { + val value = current.value + return value == "@$resourceType/$resourceName" + } + current = current.parent + depth++ } - // For code: only count elements whose class name suggests they're references - // AND whose text is EXACTLY the resource reference (not a parent container) + // For code: check for R.type.name (dots converted to underscores) val isReferenceClass = className.contains("Reference") || className.contains("Identifier") || className == "KtDotQualifiedExpression" - val hasExactText = text == "R.$resourceType.$resourceName" + val codeName = resourceName.replace(".", "_") + val hasExactText = element.text == "R.$resourceType.$codeName" return isReferenceClass && hasExactText }