diff --git a/CHANGELOG.md b/CHANGELOG.md index 70cf8c5..da021e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +## [1.5.4] - 2025-12-10 + +- 升级版本号到 1.5.4 并准备发布 +- improve CSS Modules class reference resolution and caching mechanism + ## [1.5.3] - 2025-12-10 - 将插件名称从 "React Css Modules All" 更改为 "CSS Modules" @@ -155,7 +160,8 @@ - support a little complex parents selector - support css selector has pseudo -[Unreleased]: https://github.com/Q-Peppa/react-css-modules-all/compare/v1.5.3...HEAD +[Unreleased]: https://github.com/Q-Peppa/react-css-modules-all/compare/v1.5.4...HEAD +[1.5.4]: https://github.com/Q-Peppa/react-css-modules-all/compare/v1.5.3...v1.5.4 [1.5.3]: https://github.com/Q-Peppa/react-css-modules-all/compare/v1.5.2...v1.5.3 [1.5.2]: https://github.com/Q-Peppa/react-css-modules-all/compare/v1.5.1...v1.5.2 [1.5.1]: https://github.com/Q-Peppa/react-css-modules-all/compare/v1.5.0...v1.5.1 diff --git a/gradle.properties b/gradle.properties index 75ee4b3..cbe0dce 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,8 +4,8 @@ pluginGroup = com.peppa.css pluginName = CSS Modules pluginRepositoryUrl = https://github.com/Q-Peppa/react-css-modules-all # SemVer format -> https://semver.org -pluginVersion = 1.5.3 -version = 1.5.3 +pluginVersion = 1.5.4 +version = 1.5.4 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild=242 # pluginUntilBuild = 242.* # for Supported all version > 231 diff --git a/src/main/kotlin/com/peppa/css/annotator/CssModulesClassAnnotator.kt b/src/main/kotlin/com/peppa/css/annotator/CssModulesClassAnnotator.kt index 28cfd2f..244896a 100644 --- a/src/main/kotlin/com/peppa/css/annotator/CssModulesClassAnnotator.kt +++ b/src/main/kotlin/com/peppa/css/annotator/CssModulesClassAnnotator.kt @@ -1,67 +1,49 @@ package com.peppa.css.annotator -import com.peppa.css.completion.resolveStylesheetFromReference -import com.peppa.css.psi.CssModuleClassReference -import com.peppa.css.psi.isStyleIndex import com.intellij.lang.annotation.AnnotationHolder import com.intellij.lang.annotation.Annotator import com.intellij.lang.annotation.HighlightSeverity import com.intellij.lang.javascript.psi.JSLiteralExpression import com.intellij.lang.javascript.psi.JSReferenceExpression import com.intellij.psi.PsiElement -import com.intellij.psi.css.CssRuleset -import org.jetbrains.annotations.NotNull -import java.util.Objects - +import com.peppa.css.completion.resolveStylesheetFromReference +import com.peppa.css.psi.CssModuleClassReference +import com.peppa.css.psi.isStyleIndex -const val MESSAGE = "Selector declarations is Empty" -const val UNKNOWN = "Unknown class name" +private const val MESSAGE_UNKNOWN = "Unknown class name" class CssModulesClassAnnotator : Annotator { - private fun resolveUnknownClass(holder: AnnotationHolder, psiElement: JSLiteralExpression) { - val cssSelectorName = psiElement.stringValue?.trim().orEmpty() - val reference = psiElement.reference - // Check if the reference is unresolved (CSS class doesn't exist) - if (reference is CssModuleClassReference && reference.isUnresolved()) { - val message = "$UNKNOWN \"$cssSelectorName\"" - holder.newAnnotation(HighlightSeverity.WARNING, message) - .range(psiElement) - .withFix(SimpleCssSelectorFix(cssSelectorName, reference.stylesheetFile)) - .create() - } - } + override fun annotate(psiElement: PsiElement, holder: AnnotationHolder) { + when (psiElement) { + is JSLiteralExpression if isStyleIndex(psiElement) -> + annotateStyleIndex(psiElement, holder) - private fun resolveEmptyClass(holder: AnnotationHolder, psiElement: JSLiteralExpression) { - val ruleset = psiElement.reference?.resolve() - if (ruleset is CssRuleset) { - val declarations = ruleset.block?.declarations - if (declarations.isNullOrEmpty()) { - holder.newAnnotation(HighlightSeverity.WEAK_WARNING, MESSAGE) - .range(psiElement) - .create() - } + is JSReferenceExpression -> annotateUnresolvedReference(psiElement, holder) } } - override fun annotate(@NotNull psiElement: PsiElement, @NotNull holder: AnnotationHolder) { - if (psiElement is JSLiteralExpression && isStyleIndex(psiElement)) { - resolveUnknownClass(holder, psiElement) - resolveEmptyClass(holder, psiElement) - return + private fun annotateStyleIndex(element: JSLiteralExpression, holder: AnnotationHolder) { + val reference = element.reference as? CssModuleClassReference ?: return + val className = element.stringValue?.trim().orEmpty() + + when { + reference.isUnresolved() -> holder.newAnnotation(HighlightSeverity.WARNING, "$MESSAGE_UNKNOWN \"$className\"") + .range(element) + .withFix(SimpleCssSelectorFix(className, reference.stylesheetFile)) + .create() } - if (psiElement is JSReferenceExpression && Objects.isNull(psiElement.reference?.resolve())) { - val styleFile = resolveStylesheetFromReference(psiElement) ?: return + } + private fun annotateUnresolvedReference(element: JSReferenceExpression, holder: AnnotationHolder) { + if (element.reference?.resolve() != null) return - holder.newAnnotation( - HighlightSeverity.WEAK_WARNING, - "$UNKNOWN \"${psiElement.lastChild.text}\"" - ) - .range(psiElement.lastChild) - .withFix(SimpleCssSelectorFix(psiElement.lastChild.text, styleFile)) - .create() + val styleFile = resolveStylesheetFromReference(element) ?: return + val className = element.lastChild.text - } + holder.newAnnotation(HighlightSeverity.WEAK_WARNING, "$MESSAGE_UNKNOWN \"$className\"") + .range(element.lastChild) + .withFix(SimpleCssSelectorFix(className, styleFile)) + .create() } } \ No newline at end of file diff --git a/src/main/kotlin/com/peppa/css/annotator/SimpleCssSelectorFix.kt b/src/main/kotlin/com/peppa/css/annotator/SimpleCssSelectorFix.kt index c5a06d2..288e107 100644 --- a/src/main/kotlin/com/peppa/css/annotator/SimpleCssSelectorFix.kt +++ b/src/main/kotlin/com/peppa/css/annotator/SimpleCssSelectorFix.kt @@ -26,7 +26,8 @@ class SimpleCssSelectorFix(private val key: String, private val stylesheetFile: override fun invoke(@NotNull project: Project, editor: Editor?, file: PsiFile?) { if (editor == null || file == null) return - val rulesetText = "\n.$key {\n \n}" + + val rulesetText = "\n.$key {\n\t\t\n}" val ruleset = CssElementFactory.getInstance(project).createRuleset( rulesetText, stylesheetFile.language @@ -34,16 +35,17 @@ class SimpleCssSelectorFix(private val key: String, private val stylesheetFile: stylesheetFile.navigate(true) stylesheetFile.add(ruleset) - - val newEditor = FileEditorManager.getInstance(project).selectedEditor ?:return; - if(newEditor is TextEditor) { + val newEditor = FileEditorManager.getInstance(project).selectedEditor ?: return; + if (newEditor is TextEditor) { newEditor.editor.caretModel.moveToLogicalPosition( - LogicalPosition(newEditor.editor.document.lineCount-2, 0) + LogicalPosition(newEditor.editor.document.lineCount - 2, 0) ) - newEditor.editor.scrollingModel.scrollTo(newEditor.editor.caretModel.logicalPosition,ScrollType.MAKE_VISIBLE) - DeclarativeInlayHintsPassFactory.scheduleRecompute(editor,project) - DeclarativeInlayHintsPassFactory.scheduleRecompute(newEditor.editor,project) + newEditor.editor.scrollingModel.scrollTo( + newEditor.editor.caretModel.logicalPosition, + ScrollType.MAKE_VISIBLE + ) + DeclarativeInlayHintsPassFactory.scheduleRecompute(editor, project) + DeclarativeInlayHintsPassFactory.scheduleRecompute(newEditor.editor, project) } - } } \ No newline at end of file diff --git a/src/main/kotlin/com/peppa/css/completion/CssModulesClassNameCompletionContributor.kt b/src/main/kotlin/com/peppa/css/completion/CssModulesClassNameCompletionContributor.kt index 822dbf8..94f4d36 100644 --- a/src/main/kotlin/com/peppa/css/completion/CssModulesClassNameCompletionContributor.kt +++ b/src/main/kotlin/com/peppa/css/completion/CssModulesClassNameCompletionContributor.kt @@ -9,90 +9,72 @@ import com.intellij.lang.javascript.psi.JSIndexedPropertyAccessExpression import com.intellij.lang.javascript.psi.JSLiteralExpression import com.intellij.lang.javascript.psi.JSReferenceExpression import com.intellij.patterns.PlatformPatterns -import com.intellij.psi.PsiElement import com.intellij.psi.css.StylesheetFile import com.intellij.util.ProcessingContext -const val SplitChar = "-" -const val DotChar = "." +private const val SPLIT_CHAR = "-" +private const val DOT_CHAR = "." class CssModulesClassNameCompletionContributor : CompletionContributor() { init { - val language = PlatformPatterns - .psiElement() - .withLanguage(JavascriptLanguage.INSTANCE) + val jsPattern = PlatformPatterns.psiElement().withLanguage(JavascriptLanguage.INSTANCE) extend( CompletionType.BASIC, - language + jsPattern .withParent(JSLiteralExpression::class.java) .withSuperParent(2, JSIndexedPropertyAccessExpression::class.java), - CssModulesClassNameCompletionContributorProvider() + IndexAccessCompletionProvider() ) extend( CompletionType.BASIC, - language - .withParent(JSReferenceExpression::class.java), - CssModulesClassNameCompletionContributorWithDotProvider() + jsPattern.withParent(JSReferenceExpression::class.java), + DotAccessCompletionProvider() ) } - private class CssModulesClassNameCompletionContributorProvider : CompletionProvider() { + private class IndexAccessCompletionProvider : CompletionProvider() { override fun addCompletions( parameters: CompletionParameters, context: ProcessingContext, resultSet: CompletionResultSet ) { - val position = parameters.position - val stylesheetFile = findReferenceStyleFile(position.parent as JSLiteralExpression) ?: return + val literal = parameters.position.parent as? JSLiteralExpression ?: return + val stylesheetFile = findReferenceStyleFile(literal) ?: return resultSet.addAllElements(generateLookupElementList(stylesheetFile)) } } - private class CssModulesClassNameCompletionContributorWithDotProvider : CompletionProvider() { + private class DotAccessCompletionProvider : CompletionProvider() { override fun addCompletions( parameters: CompletionParameters, context: ProcessingContext, resultSet: CompletionResultSet ) { - val position = parameters.position - if (position.prevSibling is PsiElement - && position.prevSibling.text == DotChar - && position.prevSibling.prevSibling is JSReferenceExpression - ) { - val style = position.prevSibling.prevSibling - style.reference?.resolve()?.let { stylesFileImportStatement -> - if (stylesFileImportStatement !is ES6ImportedBinding || stylesFileImportStatement.findReferencedElements() - .isEmpty() - ) return - val first = stylesFileImportStatement.findReferencedElements().firstOrNull() - first?.let { - resultSet.addAllElements(generateLookupElementList(it as StylesheetFile, true).map { element -> - // if choose completion with - , auto make to IndexedAccess - LookupElementDecorator.withInsertHandler(element) { context, item -> - StylesInsertHandler(item.lookupString.contains(SplitChar)).handleInsert(context, item) - } - }) + val prevSibling = position.prevSibling ?: return + if (prevSibling.text != DOT_CHAR) return + + val styleRef = prevSibling.prevSibling as? JSReferenceExpression ?: return + val binding = styleRef.reference?.resolve() as? ES6ImportedBinding ?: return + val stylesheetFile = binding.findReferencedElements().firstOrNull() as? StylesheetFile ?: return + + val elements = generateLookupElementList(stylesheetFile, true).map { element -> + LookupElementDecorator.withInsertHandler(element) { ctx, item -> + if (item.lookupString.contains(SPLIT_CHAR)) { + convertToBracketSyntax(ctx, item.lookupString) } } } + resultSet.addAllElements(elements) } - } - private class StylesInsertHandler(private val needsBracketSyntax: Boolean) : InsertHandler { - override fun handleInsert(context: InsertionContext, item: LookupElement) { - if (needsBracketSyntax) { - val editor = context.editor - val document = editor.document - val startOffset = context.startOffset - val dotPosOffset = startOffset - 1 - val tailOffset = context.tailOffset - val lookupString = item.lookupString - document.replaceString(dotPosOffset, tailOffset, "[$lookupString]") - editor.caretModel.moveToOffset(dotPosOffset + lookupString.length + 2) - } + private fun convertToBracketSyntax(context: InsertionContext, lookupString: String) { + val document = context.editor.document + val dotOffset = context.startOffset - 1 + document.replaceString(dotOffset, context.tailOffset, "['$lookupString']") + context.editor.caretModel.moveToOffset(dotOffset + lookupString.length + 4) } } diff --git a/src/main/kotlin/com/peppa/css/completion/QCssModulesUtil.kt b/src/main/kotlin/com/peppa/css/completion/QCssModulesUtil.kt index a09f6ea..1f44207 100644 --- a/src/main/kotlin/com/peppa/css/completion/QCssModulesUtil.kt +++ b/src/main/kotlin/com/peppa/css/completion/QCssModulesUtil.kt @@ -10,7 +10,10 @@ import com.intellij.lang.javascript.psi.JSLiteralExpression import com.intellij.lang.javascript.psi.JSReferenceExpression import com.intellij.openapi.util.text.Strings import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile import com.intellij.psi.PsiReference +import com.intellij.psi.SmartPointerManager +import com.intellij.psi.SmartPsiElementPointer import com.intellij.psi.css.* import com.intellij.psi.css.impl.CssEscapeUtil import com.intellij.psi.css.impl.stubs.index.CssIndexUtil @@ -19,33 +22,88 @@ import com.intellij.psi.presentation.java.SymbolPresentationUtil import com.intellij.psi.search.GlobalSearchScope import com.intellij.psi.util.CachedValueProvider import com.intellij.psi.util.CachedValuesManager -import com.intellij.psi.util.PsiModificationTracker import com.intellij.psi.util.PsiTreeUtil import com.intellij.util.PathUtil +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.psi.PsiManager +import java.io.File private fun recessivesClassInCssSelector( realSelector: CssSelector, cssSelector: CssSelector, - map: MutableMap + map: MutableMap>, + pointerManager: SmartPointerManager ): Boolean { for (clazz in PsiTreeUtil.findChildrenOfType(cssSelector, CssClass::class.java)) { if (Strings.isEmpty(clazz.name)) continue - map[clazz.name!!] = realSelector.ruleset!! + realSelector.ruleset?.let { map[clazz.name!!] = pointerManager.createSmartPsiElementPointer(it) } } return true } +private val IMPORT_STATEMENT_REGEX = Regex("@(?:import|use)[^;]*;") +private val QUOTED_PATH_REGEX = Regex("['\"]([^'\"]+)['\"]") + +private fun resolveImportPaths(baseDir: File, projectBase: String?, importPath: String): List { + // Normalize path segments + val cleanPath = importPath.replace("\\", "/") + val lastSlash = cleanPath.lastIndexOf('/') + val dirPart = if (lastSlash >= 0) cleanPath.take(lastSlash) else "" + val namePart = if (lastSlash >= 0) cleanPath.substring(lastSlash + 1) else cleanPath + + val candidates = mutableListOf() + val exts = listOf(".scss", ".sass", ".css") + + // If importPath already has an extension, prefer that exact file and its partial + val hasExt = exts.any { namePart.endsWith(it) } + if (hasExt) { + candidates += cleanPath + // add partial with underscore + val idx = namePart.lastIndexOf('.') + val bare = if (idx >= 0) namePart.take(idx) else namePart + val ext = if (idx >= 0) namePart.substring(idx) else "" + val partial = if (dirPart.isEmpty()) "_${bare}${ext}" else "$dirPart/_${bare}${ext}" + candidates += partial + } else { + for (ext in exts) { + val p1 = if (dirPart.isEmpty()) "$namePart$ext" else "$dirPart/$namePart$ext" + val p2 = if (dirPart.isEmpty()) "_${namePart}$ext" else "$dirPart/_${namePart}$ext" + candidates += listOf(p1, p2) + } + } + + // Also consider node-style ~ resolution relative to project base + val files = mutableListOf() + for (c in candidates) { + // relative to baseDir + files += File(baseDir, c) + // if project base provided and import starts with ~ or not found, try project base + if (projectBase != null) { + files += File(projectBase, c) + if (c.startsWith("~")) files += File(projectBase, c.removePrefix("~")) + } + } + return files +} + /** - * return all available css className in file map , foo-> some ruleset + * return all available css className in file map , foo-> some ruleset pointer + * 支持递归解析 scss 的 @import 与 @use(有限的相对路径、partial 和扩展名尝试) */ -fun restoreAllSelector(stylesheetFile: StylesheetFile): MutableMap = - CachedValuesManager.getCachedValue(stylesheetFile) { - val of = mutableMapOf() +fun restoreAllSelector(stylesheetFile: StylesheetFile): Map> { + val pointerManager = SmartPointerManager.getInstance(stylesheetFile.project) + val psiManager = PsiManager.getInstance(stylesheetFile.project) + val projectBase = stylesheetFile.project.basePath + + return CachedValuesManager.getCachedValue(stylesheetFile) { + val of = mutableMapOf>() val scope = GlobalSearchScope.fileScope(stylesheetFile.project, stylesheetFile.virtualFile) + val dependencyFiles = mutableListOf(stylesheetFile) + // 处理当前文件的选择器 CssIndexUtil.processAmpersandSelectors(stylesheetFile.project, scope) { selector -> selector.processAmpersandEvaluatedSelectors { evaluated -> - recessivesClassInCssSelector(selector, evaluated, of) + recessivesClassInCssSelector(selector, evaluated, of, pointerManager) } true } @@ -54,12 +112,47 @@ fun restoreAllSelector(stylesheetFile: StylesheetFile): MutableMap - of[name] = css.ruleset!! + css.ruleset?.let { of[name] = pointerManager.createSmartPsiElementPointer(it) } true } - CachedValueProvider.Result.create(of, stylesheetFile, PsiModificationTracker.MODIFICATION_COUNT) + // 解析 @import / @use,递归合并 + try { + val virtualFile = stylesheetFile.virtualFile + val baseDir = virtualFile?.let { File(it.path).parentFile } ?: File(stylesheetFile.containingDirectory?.virtualFile?.path ?: projectBase ?: "") + val text = stylesheetFile.text + + // 找到所有 @import/@use 语句,然后提取每个语句中的所有引号路径 + val imports = IMPORT_STATEMENT_REGEX.findAll(text).flatMap { stmt -> + QUOTED_PATH_REGEX.findAll(stmt.value).mapNotNull { it.groups[1]?.value } + }.toList() + + val visited = mutableSetOf() + + fun collectFromImported(path: String) { + val candidates = resolveImportPaths(baseDir, projectBase, path) + for (f in candidates) { + val abs = try { f.canonicalPath } catch (_: Exception) { f.absolutePath } + if (abs in visited) continue + if (!f.exists()) continue + val vf = LocalFileSystem.getInstance().findFileByIoFile(f) ?: continue + val psi = psiManager.findFile(vf) as? StylesheetFile ?: continue + visited += abs + dependencyFiles += psi + // 合并被导入文件的选择器(利用被导入文件自身的缓存) + val imported = restoreAllSelector(psi) + for ((k, ptr) in imported) of.putIfAbsent(k, ptr) + } + } + + for (imp in imports) collectFromImported(imp) + } catch (_: Exception) { + // 保守处理:若解析导入失败,不阻塞主流程 + } + + CachedValueProvider.Result.create(of.toMap(), *dependencyFiles.toTypedArray()) } +} const val SpaceSize = 2 @@ -76,7 +169,6 @@ fun buildLookupElementHelper( .bold() .withPsiElement(css) .withIcon(AllIcons.Xml.Css_class) - // the PresentableText will be wrap by ' ' if name has - ; .withPresentableText(lookup) .withCaseSensitivity(true) .withTailText(" ".repeat(SpaceSize) + "($location:$lineNumber)", true) @@ -85,24 +177,40 @@ fun buildLookupElementHelper( } private fun toGetStylesheetFile(ref: PsiReference?): StylesheetFile? { - val resolve = ref?.resolve() as? ES6ImportedBinding ?: return null - return resolve.findReferencedElements().firstOrNull() as? StylesheetFile + // 增强解析逻辑:支持直接 resolve 到 StylesheetFile、PsiFile,或 ES6ImportedBinding, + // 并尝试沿引用链继续解析,提升命中率和鲁棒性。 + val resolved = ref?.resolve() ?: return null + return when (resolved) { + is StylesheetFile -> resolved + is ES6ImportedBinding -> resolved.findReferencedElements().firstOrNull() as? StylesheetFile + is PsiFile -> null + else -> { + // 作为兜底:如果 resolve 到了一个引用表达式(alias 等),尝试继续解析一次 + (resolved as? JSReferenceExpression)?.reference?.resolve() as? StylesheetFile + } + } } -/** - * 解析引用处的样式文件。支持 styles["xxx"] 与 styles.xxx 两种用法,集中解析以避免重复逻辑。 - */ -fun resolveStylesheetFromReference(element: PsiElement?): StylesheetFile? = when (element) { - is JSLiteralExpression -> { - val callKey = (element.parent as? JSIndexedPropertyAccessExpression)?.firstChild as? JSReferenceExpression - ?: return null - toGetStylesheetFile(callKey.reference) +fun resolveStylesheetFromReference(element: PsiElement?): StylesheetFile? { + if (element == null) return null + + // 字符串字面量的情况:查找最近的引用表达式(支持多种父级结构) + if (element is JSLiteralExpression) { + // 优先查找索引访问 foo["bar"] 的首子表达式 + val indexed = PsiTreeUtil.getParentOfType(element, JSIndexedPropertyAccessExpression::class.java) + val candidateRef = (indexed?.firstChild as? JSReferenceExpression) + // 否则向上查找通用的 JSReferenceExpression(例如 foo.bar 或 更复杂结构) + ?: PsiTreeUtil.getParentOfType(element, JSReferenceExpression::class.java) + + return toGetStylesheetFile(candidateRef?.reference) } - is JSReferenceExpression -> { - toGetStylesheetFile(element.firstChild?.reference) + // 直接是引用表达式:优先用自身的 reference,再退回到 firstChild 的 reference(兼容旧逻辑) + if (element is JSReferenceExpression) { + return toGetStylesheetFile(element.reference ?: element.firstChild?.reference) } - else -> null + + return null } /** @@ -120,8 +228,9 @@ fun generateLookupElementList( val shortLocation = PathUtil.toSystemIndependentName(SymbolPresentationUtil.getFilePathPresentation(stylesheetFile)) val allSelector = restoreAllSelector(stylesheetFile) - val allLookupElement = allSelector.entries.map { - buildLookupElementHelper(it.key, it.value, shortLocation, isDotCompletion && it.key.contains("-")) + val allLookupElement = allSelector.entries.mapNotNull { + val psi = it.value.element ?: return@mapNotNull null + buildLookupElementHelper(it.key, psi, shortLocation, isDotCompletion && it.key.contains("-")) } return allLookupElement } \ No newline at end of file diff --git a/src/main/kotlin/com/peppa/css/document/SimpleDocumentationProvider.kt b/src/main/kotlin/com/peppa/css/document/SimpleDocumentationProvider.kt index 056f119..c6ddba3 100644 --- a/src/main/kotlin/com/peppa/css/document/SimpleDocumentationProvider.kt +++ b/src/main/kotlin/com/peppa/css/document/SimpleDocumentationProvider.kt @@ -4,44 +4,37 @@ import com.intellij.lang.css.CSSLanguage import com.intellij.lang.documentation.AbstractDocumentationProvider import com.intellij.lang.documentation.QuickDocHighlightingHelper.appendStyledCodeBlock import com.intellij.lang.javascript.psi.JSLiteralExpression +import com.intellij.lang.javascript.psi.JSReferenceExpression import com.intellij.psi.PsiElement import com.intellij.psi.css.CssRuleset -private fun String.replaceLast(target: String, replacement: String): String { - val index = this.lastIndexOf(target) - return if (index != -1) { - this.substring(0, index) + replacement + this.substring(index + target.length) - } else { - this // 如果没有找到匹配项,返回原字符串 - } +private fun String.replaceLast(oldValue: String, newValue: String): String { + val index = this.lastIndexOf(oldValue) + return if (index < 0) this else this.substring(0, index) + newValue + this.substring(index + oldValue.length) } - class SimpleDocumentationProvider : AbstractDocumentationProvider() { + + override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? = + resolveCssRuleset(element, originalElement) + ?: super.generateDoc(element, originalElement) + + override fun getQuickNavigateInfo(element: PsiElement?, originalElement: PsiElement?): String? = + resolveCssRuleset(element, originalElement) + ?: super.getQuickNavigateInfo(element, originalElement) + + private fun resolveCssRuleset(element: PsiElement?, context: PsiElement?): String? { + if (element !is CssRuleset || context == null) return null + val isStylesAccess = context is JSLiteralExpression + || context.parent is JSLiteralExpression + || context is JSReferenceExpression + || context.parent is JSReferenceExpression + return if (isStylesAccess) renderDoc(element) else null + } + private fun renderDoc(cssRuleset: CssRuleset): String { val text = cssRuleset.text.trimIndent().replaceLast("}", "").trim() + "\n}" return StringBuilder() .appendStyledCodeBlock(cssRuleset.project, CSSLanguage.INSTANCE, code = text) .toString() } - - override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? { - if (element == null || originalElement == null) return null - - val origin = super.generateDoc(element, originalElement) - // Check if the element is a CssRuleset and the originalElement is a JSLiteralExpression - if (element is CssRuleset && originalElement.parent is JSLiteralExpression) { - return renderDoc(element) - } - return origin - } - - /** - * override getQuickNavigateInfo when ctrl/cmd + mouse left hover show css ruleset - */ - override fun getQuickNavigateInfo(element: PsiElement?, originalElement: PsiElement?): String? { - if (element is CssRuleset && originalElement is JSLiteralExpression) { - return renderDoc(element) - } - return super.getQuickNavigateInfo(element, originalElement) - } } \ No newline at end of file diff --git a/src/main/kotlin/com/peppa/css/psi/CssModulesIndexedStylesVarPsiReferenceContributor.kt b/src/main/kotlin/com/peppa/css/psi/CssModulesIndexedStylesVarPsiReferenceContributor.kt index f34ae49..9b99e4e 100644 --- a/src/main/kotlin/com/peppa/css/psi/CssModulesIndexedStylesVarPsiReferenceContributor.kt +++ b/src/main/kotlin/com/peppa/css/psi/CssModulesIndexedStylesVarPsiReferenceContributor.kt @@ -1,71 +1,46 @@ package com.peppa.css.psi - -import com.peppa.css.completion.findReferenceStyleFile import com.intellij.lang.javascript.JSTokenTypes import com.intellij.lang.javascript.psi.JSFile import com.intellij.lang.javascript.psi.JSIndexedPropertyAccessExpression import com.intellij.lang.javascript.psi.JSLiteralExpression import com.intellij.patterns.PlatformPatterns -import com.intellij.psi.* +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiReference +import com.intellij.psi.PsiReferenceContributor +import com.intellij.psi.PsiReferenceProvider +import com.intellij.psi.PsiReferenceRegistrar import com.intellij.util.ProcessingContext -import org.jetbrains.annotations.NotNull +import com.peppa.css.completion.findReferenceStyleFile +class CssModulesIndexedStylesVarPsiReferenceContributor : PsiReferenceContributor() { -/** - * Reference provider for styles["className"] syntax. - * All validation logic is centralized here to avoid duplicate PSI resolution. - */ -class CssModuleIndexedReferenceProvider : PsiReferenceProvider() { + override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) { + registrar.registerReferenceProvider(INDEXED_ACCESS_FILTER, IndexedReferenceProvider()) + } - override fun getReferencesByElement( - element: PsiElement, - context: ProcessingContext - ): Array { - if (element !is JSLiteralExpression) return PsiReference.EMPTY_ARRAY + companion object { + private val INDEXED_ACCESS_FILTER = PlatformPatterns + .psiElement(JSLiteralExpression::class.java) + .withParent(JSIndexedPropertyAccessExpression::class.java) + .inFile(PlatformPatterns.psiFile(JSFile::class.java)) + } +} - // Fast check: must be a string literal token - if (element.node.firstChildNode?.elementType != JSTokenTypes.STRING_LITERAL) { - return PsiReference.EMPTY_ARRAY - } +private class IndexedReferenceProvider : PsiReferenceProvider() { - // Fast check: must be inside indexed property access (styles["xxx"]) - if (element.parent !is JSIndexedPropertyAccessExpression) { + override fun getReferencesByElement(element: PsiElement, context: ProcessingContext): Array { + val literal = element as? JSLiteralExpression ?: return PsiReference.EMPTY_ARRAY + + if (literal.node.firstChildNode?.elementType != JSTokenTypes.STRING_LITERAL) { return PsiReference.EMPTY_ARRAY } - val name = element.stringValue?.trim().orEmpty() - if (name.isBlank()) return PsiReference.EMPTY_ARRAY - - // Expensive check: resolve to style file (done only once here) - val styleFile = findReferenceStyleFile(element) ?: return PsiReference.EMPTY_ARRAY - - // Always return a dynamic reference that resolves fresh each time - return arrayOf(CssModuleClassReference(element, styleFile, name)) - } -} - -/** - * Simplified filter - only does basic type matching. - * Expensive PSI resolution is deferred to the provider. - */ -private val INDEXED_ACCESS_FILTER = PlatformPatterns - .psiElement(JSLiteralExpression::class.java) - .withParent(JSIndexedPropertyAccessExpression::class.java) - .inFile(PlatformPatterns.psiFile(JSFile::class.java)) + val name = literal.stringValue?.trim()?.takeIf { it.isNotBlank() } ?: return PsiReference.EMPTY_ARRAY + val styleFile = findReferenceStyleFile(literal) ?: return PsiReference.EMPTY_ARRAY - -class CssModulesIndexedStylesVarPsiReferenceContributor : PsiReferenceContributor() { - override fun registerReferenceProviders(@NotNull registrar: PsiReferenceRegistrar) { - // Register provider for styles["className"] syntax - registrar.registerReferenceProvider( - INDEXED_ACCESS_FILTER, CssModuleIndexedReferenceProvider() - ) + return arrayOf(CssModuleClassReference(literal, styleFile, name)) } } -/** - * Check if the element is a style index expression (styles["className"]). - * This function is used by other parts of the codebase (e.g., annotator). - */ fun isStyleIndex(element: JSLiteralExpression): Boolean = findReferenceStyleFile(element) != null \ No newline at end of file diff --git a/src/main/kotlin/com/peppa/css/psi/CssModulesUnknownClassPsiReference.kt b/src/main/kotlin/com/peppa/css/psi/CssModulesUnknownClassPsiReference.kt index 304164f..a4de71c 100644 --- a/src/main/kotlin/com/peppa/css/psi/CssModulesUnknownClassPsiReference.kt +++ b/src/main/kotlin/com/peppa/css/psi/CssModulesUnknownClassPsiReference.kt @@ -3,26 +3,51 @@ package com.peppa.css.psi import com.peppa.css.completion.restoreAllSelector import com.intellij.psi.PsiElement import com.intellij.psi.PsiReferenceBase +import com.intellij.psi.SmartPsiElementPointer import com.intellij.psi.css.StylesheetFile /** * A dynamic PSI reference for CSS class names. - * The resolve() method dynamically looks up the CSS selector each time it's called, - * ensuring that changes to the CSS file are immediately reflected. */ -class CssModuleClassReference( +class CssModuleClassReference @JvmOverloads constructor( element: PsiElement, val stylesheetFile: StylesheetFile, - private val className: String + private val className: String, + private val selectorProvider: (StylesheetFile) -> Map> = { restoreAllSelector(it) }, + private val onResolve: (() -> Unit)? = null, + private val enableCache: Boolean = true ) : PsiReferenceBase(element) { + // 基于 stylesheetFile.modificationStamp 的简单缓存,减少重复解析 + @Volatile + private var cachedStamp: Long = -1L + + @Volatile + private var cachedMap: Map> = emptyMap() + /** * Dynamically resolves the CSS class name. This is called each time the reference * needs to be resolved, ensuring fresh results after CSS file modifications. */ override fun resolve(): PsiElement? { - val map = restoreAllSelector(stylesheetFile) - return map[className] + onResolve?.invoke() + + if (!enableCache) { + // 每次都调用 provider;用于测试/比较场景 + return selectorProvider(stylesheetFile)[className]?.element + } + + val currentStamp = stylesheetFile.modificationStamp + if (currentStamp != cachedStamp) { + synchronized(this) { + val stampNow = stylesheetFile.modificationStamp + if (stampNow != cachedStamp) { + cachedMap = selectorProvider(stylesheetFile) + cachedStamp = stampNow + } + } + } + return cachedMap[className]?.element } /** @@ -31,4 +56,3 @@ class CssModuleClassReference( */ fun isUnresolved(): Boolean = resolve() == null } - diff --git a/src/test/kotlin/com/peppa/css/psi/CssModuleClassReferenceBenchmarkTest.kt b/src/test/kotlin/com/peppa/css/psi/CssModuleClassReferenceBenchmarkTest.kt new file mode 100644 index 0000000..b9c773c --- /dev/null +++ b/src/test/kotlin/com/peppa/css/psi/CssModuleClassReferenceBenchmarkTest.kt @@ -0,0 +1,33 @@ +package com.peppa.css.psi + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlin.system.measureNanoTime + +class CssModuleClassReferenceBenchmarkTest : BasePlatformTestCase() { + + fun testBenchmarkCacheVsNoCache() { + myFixture.configureByText("a.module.css", (1..100).joinToString("\n") { ".c$it { color: black }" }) + val file = myFixture.file + val stylesheet = file as? com.intellij.psi.css.StylesheetFile + ?: error("expected StylesheetFile") + + val refNoCache = CssModuleClassReference(myFixture.file, stylesheet, "c1", enableCache = false) + val refCache = CssModuleClassReference(myFixture.file, stylesheet, "c1", enableCache = true) + + val runs = 5000 + + val timeNoCache = measureNanoTime { + repeat(runs) { refNoCache.resolve() } + } + + val timeCache = measureNanoTime { + repeat(runs) { refCache.resolve() } + } + + println("no-cache: $timeNoCache ns, cache: $timeCache ns, ratio: ${timeNoCache.toDouble()/timeCache}") + + // 主要断言还是 provider 调用次数应有所差异;时间仅作参考 + assertTrue(timeCache < timeNoCache) + } +} + diff --git a/src/test/kotlin/com/peppa/css/psi/CssModuleClassReferenceCachingTest.kt b/src/test/kotlin/com/peppa/css/psi/CssModuleClassReferenceCachingTest.kt new file mode 100644 index 0000000..d41ea5c --- /dev/null +++ b/src/test/kotlin/com/peppa/css/psi/CssModuleClassReferenceCachingTest.kt @@ -0,0 +1,71 @@ +package com.peppa.css.psi + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.util.concurrent.atomic.AtomicInteger +import com.intellij.psi.SmartPsiElementPointer +import com.intellij.psi.PsiElement + +class CssModuleClassReferenceCachingTest : BasePlatformTestCase() { + + fun testProviderCalledEveryTimeWhenCacheDisabled() { + myFixture.configureByText("a.module.css", ".foo { } .bar { }") + val file = myFixture.file + val stylesheet = file as? com.intellij.psi.css.StylesheetFile + ?: error("expected StylesheetFile") + + val counter = AtomicInteger() + val provider: (com.intellij.psi.css.StylesheetFile) -> Map> = { + counter.incrementAndGet(); emptyMap() + } + + val ref = CssModuleClassReference(myFixture.file, stylesheet, "foo", selectorProvider = provider, enableCache = false) + + repeat(10) { ref.resolve() } + assertEquals(10, counter.get()) + } + + fun testProviderCalledOnceWhenCacheEnabled() { + myFixture.configureByText("a.module.css", ".foo { } .bar { }") + val file = myFixture.file + val stylesheet = file as? com.intellij.psi.css.StylesheetFile + ?: error("expected StylesheetFile") + + val counter = AtomicInteger() + val provider: (com.intellij.psi.css.StylesheetFile) -> Map> = { + counter.incrementAndGet(); emptyMap() + } + + val ref = CssModuleClassReference(myFixture.file, stylesheet, "foo", selectorProvider = provider, enableCache = true) + + repeat(10) { ref.resolve() } + assertEquals(1, counter.get()) + } + + fun testCacheInvalidatedWhenModificationStampChanges() { + myFixture.configureByText("a.module.css", ".foo { } .bar { }") + val file = myFixture.file + val stylesheet = file as? com.intellij.psi.css.StylesheetFile + ?: error("expected StylesheetFile") + + val counter = AtomicInteger() + val provider: (com.intellij.psi.css.StylesheetFile) -> Map> = { + counter.incrementAndGet(); emptyMap() + } + + val ref = CssModuleClassReference(myFixture.file, stylesheet, "foo", selectorProvider = provider, enableCache = true) + + // 首次解析 + ref.resolve() + val firstCount = counter.get() + + // 修改文件触发 stamp 变更 + myFixture.configureByText("a.module.css", ".foo { color: red } .bar { }") + + // 再次解析应触发 provider + ref.resolve() + val secondCount = counter.get() + + assertTrue(firstCount >= 1) + assertTrue(secondCount > firstCount) + } +}