From 1402c21c81af3db415e1e9cecbbd408f1f4f886e Mon Sep 17 00:00:00 2001 From: WalterWoshid Date: Wed, 11 Feb 2026 13:16:24 +0000 Subject: [PATCH 1/2] chore: target PhpStorm (PS) 2025.3.2 --- gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 3c36826..d4d9f3b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,8 +10,8 @@ pluginVersion = 2026.1.5 pluginSinceBuild = 251 # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension -platformType = IU -platformVersion = 2025.1.1 +platformType = PS +platformVersion = 2025.3.2 # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html # Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP From 3a848ce04e331ea6118420d3dc8b262441e8ffaa Mon Sep 17 00:00:00 2001 From: WalterWoshid Date: Wed, 11 Feb 2026 13:16:36 +0000 Subject: [PATCH 2/2] feat: add linter and guard, apply fixes and suppress by scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Run mago lint and guard with analyze; merge results into annotations - Apply single fix or apply-all by safety (safe / potentially unsafe / unsafe) - After apply: clear problem cache and restart daemon to avoid stale fixes when applying again while Mago is re-analyzing (3–5 s) - Format after fix: run Mago formatter when the option is enabled in settings - Mago: Suppress for statement, function, method, class (submenu when multiple); always show "for statement" so narrowest scope is available - Intention preview (Ctrl+Q) for apply and suppress - Remove ReformatFileAction; fix DaemonCodeAnalyzer.restart deprecation --- .../mago/configuration/MagoConfigurable.kt | 32 ++- .../configuration/MagoProjectConfiguration.kt | 7 +- .../mago/formatter/MagoReformatFileAction.kt | 12 - .../mago/qualityTool/MagoAnnotatorProxy.kt | 55 +++- .../mago/qualityTool/MagoApplyEditAction.kt | 181 +++++++++++++ .../mago/qualityTool/MagoGlobalInspection.kt | 2 +- .../mago/qualityTool/MagoHtmlAnnotator.kt | 246 ++++++++++++++++++ .../qualityTool/MagoJsonMessageHandler.kt | 49 +++- .../mago/qualityTool/MagoMessageProcessor.kt | 150 +++++++---- .../qualityTool/MagoProblemDescription.kt | 15 ++ .../qualityTool/MagoReformatFileAction.kt | 22 -- .../mago/qualityTool/MarkIgnoreAction.kt | 193 +++++++++++++- src/main/resources/META-INF/plugin.xml | 3 + .../resources/messages/MagoBundle.properties | 5 +- 14 files changed, 849 insertions(+), 123 deletions(-) delete mode 100644 src/main/kotlin/com/github/xepozz/mago/formatter/MagoReformatFileAction.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoApplyEditAction.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoHtmlAnnotator.kt delete mode 100644 src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoReformatFileAction.kt diff --git a/src/main/kotlin/com/github/xepozz/mago/configuration/MagoConfigurable.kt b/src/main/kotlin/com/github/xepozz/mago/configuration/MagoConfigurable.kt index 5970fa8..73f4acc 100644 --- a/src/main/kotlin/com/github/xepozz/mago/configuration/MagoConfigurable.kt +++ b/src/main/kotlin/com/github/xepozz/mago/configuration/MagoConfigurable.kt @@ -115,6 +115,12 @@ class MagoConfigurable(val project: Project) : Configurable { browserLink("Documentation", "https://mago.carthage.software/tools/formatter/overview") .align(AlignX.RIGHT) }.layout(RowLayout.PARENT_GRID) + row { + cell(OnOffButton()) + .label(MagoBundle.message("settings.formatter.formatAfterFix")) + .bindSelected(settings::formatAfterFix) + comment(MagoBundle.message("settings.formatter.formatAfterFix.comment")) + }.layout(RowLayout.PARENT_GRID) row { textField() .label("Additional parameters") @@ -130,11 +136,14 @@ class MagoConfigurable(val project: Project) : Configurable { .bindSelected(settings::linterEnabled) browserLink("Documentation", "https://mago.carthage.software/tools/linter/overview") .align(AlignX.RIGHT) - } - .layout(RowLayout.PARENT_GRID) - .visible(true) - .enabled(false) - .comment("Not implemented yet.") + }.layout(RowLayout.PARENT_GRID) + row { + textField() + .label("Additional parameters") + .bindText(settings::lintAdditionalParameters) + .comment("Read more: mago lint --help") + .align(AlignX.FILL) + }.layout(RowLayout.PARENT_GRID) } group(MagoBundle.message("settings.guard.title")) { row { @@ -143,11 +152,14 @@ class MagoConfigurable(val project: Project) : Configurable { .bindSelected(settings::guardEnabled) browserLink("Documentation", "https://mago.carthage.software/tools/guard/overview") .align(AlignX.RIGHT) - } - .layout(RowLayout.PARENT_GRID) - .visible(true) - .enabled(false) - .comment("Not implemented yet.") + }.layout(RowLayout.PARENT_GRID) + row { + textField() + .label("Additional parameters") + .bindText(settings::guardAdditionalParameters) + .comment("Read more: mago guard --help") + .align(AlignX.FILL) + }.layout(RowLayout.PARENT_GRID) } } diff --git a/src/main/kotlin/com/github/xepozz/mago/configuration/MagoProjectConfiguration.kt b/src/main/kotlin/com/github/xepozz/mago/configuration/MagoProjectConfiguration.kt index 85cd122..ef1294f 100644 --- a/src/main/kotlin/com/github/xepozz/mago/configuration/MagoProjectConfiguration.kt +++ b/src/main/kotlin/com/github/xepozz/mago/configuration/MagoProjectConfiguration.kt @@ -17,8 +17,13 @@ class MagoProjectConfiguration : QualityToolProjectConfiguration(MagoReformatFile()) { -// override fun getFamilyName() = MagoBundle.message("quality.tool.mago") -// -// override fun getText() = MagoBundle.message("quality.tool.mago.quick.fix.text") -// -// override fun getInspection(project: Project, file: PsiFile): MagoValidationInspection? { -// return InspectionProjectProfileManager.getInstance(project) -// .currentProfile.getUnwrappedTool(MagoValidationInspection().shortName, file) as MagoValidationInspection? -// } -//} diff --git a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoAnnotatorProxy.kt b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoAnnotatorProxy.kt index 1e2767a..c7a1a91 100644 --- a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoAnnotatorProxy.kt +++ b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoAnnotatorProxy.kt @@ -57,16 +57,48 @@ open class MagoAnnotatorProxy : QualityToolAnnotator() add("analyze") add(toWorkspaceRelativePath(workspace, filePath)) add("--reporting-format=json") -// filePath?.let { add(it) } + addAll(ParametersList.parse(settings.analyzeAdditionalParameters)) + }.apply { + DebugLogger.inform( + project, + "Analyze options", + """Options: ${joinToString(" ")}, filePath: $filePath""", + ) + } + + fun getLintOptions(settings: MagoProjectConfiguration, project: Project, filePath: String) = buildList { + val workspace = findWorkspace(project, filePath) + addWorkspace(workspace, project) + addConfig(workspace, project, settings) + + add("lint") + add(toWorkspaceRelativePath(workspace, filePath)) + add("--reporting-format=json") + addAll(ParametersList.parse(settings.lintAdditionalParameters)) + }.apply { + DebugLogger.inform( + project, + "Lint options", + """Options: ${joinToString(" ")}, filePath: $filePath""", + ) + } + + fun getGuardOptions(settings: MagoProjectConfiguration, project: Project, filePath: String) = buildList { + val workspace = findWorkspace(project, filePath) + addWorkspace(workspace, project) + addConfig(workspace, project, settings) + + add("guard") + add(toWorkspaceRelativePath(workspace, filePath)) + add("--reporting-format=json") + addAll(ParametersList.parse(settings.guardAdditionalParameters)) + }.apply { + DebugLogger.inform( + project, + "Guard options", + """Options: ${joinToString(" ")}, filePath: $filePath""", + ) } - .plus(ParametersList.parse(settings.analyzeAdditionalParameters)) - .apply { - DebugLogger.inform( - project, - "Analyze options", - """Options: ${joinToString(" ")}, filePath: $filePath""", - ) - } private fun toWorkspaceRelativePath(workspace: VirtualFile, absoluteFilePath: String): String { return toRelativePath(workspace.path, absoluteFilePath) @@ -136,7 +168,10 @@ open class MagoAnnotatorProxy : QualityToolAnnotator() checkNotNull(filePath) val settings = project.getService(MagoProjectConfiguration::class.java) - return getAnalyzeOptions(settings, project, filePath) + val options = mutableListOf() + options.addAll(getAnalyzeOptions(settings, project, filePath)) + + return options } override fun getQualityToolType() = MagoQualityToolType.INSTANCE diff --git a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoApplyEditAction.kt b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoApplyEditAction.kt new file mode 100644 index 0000000..e796bac --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoApplyEditAction.kt @@ -0,0 +1,181 @@ +package com.github.xepozz.mago.qualityTool + +import com.github.xepozz.mago.configuration.MagoProjectConfiguration +import com.github.xepozz.mago.formatter.MagoExternalFormatter +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer +import com.intellij.codeInsight.intention.FileModifier +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.codeInsight.intention.IntentionActionWithOptions +import com.intellij.codeInsight.intention.PriorityAction +import com.intellij.codeInsight.intention.preview.IntentionPreviewUtils +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.io.FileUtil +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import java.nio.charset.StandardCharsets +import java.nio.file.Paths + +enum class ApplyAllScope(val maxSafetyLevel: Int, val label: String) { + SAFE_ONLY(0, "fixes only safe"), + POTENTIALLY_UNSAFE(1, "potentially unsafe"), + UNSAFE(2, "unsafe") +} + +fun safetyLevel(safety: String): Int = when (safety) { + "unsafe" -> 2 + "potentiallyunsafe" -> 1 + else -> 0 +} + +fun MagoEdit.maxSafetyLevel(): Int = replacements.maxOfOrNull { safetyLevel(it.safety) } ?: 0 + +/** Normalize path for comparison so edit path (e.g. with ./) matches IDE path. */ +private fun normalizePath(path: String): String = FileUtil.toCanonicalPath(path) + +/** True if this edit applies to the given file (path or name match, paths normalized). */ +private fun editMatchesFile(edit: MagoEdit, filePath: String?, fileName: String): Boolean { + if (filePath != null) { + if (FileUtil.pathsEqual(normalizePath(filePath), normalizePath(edit.path))) return true + } + if (edit.name == fileName) return true + val editLastName = Paths.get(edit.name).fileName?.toString() + if (editLastName == fileName) return true + if (edit.name.endsWith("/$fileName") || edit.name.endsWith("\\$fileName")) return true + return false +} + +/** Keep only replacements with exactly this safety level (safe=0, potentially unsafe=1, unsafe=2). */ +fun filterEditsByExactSafety(edits: List, level: Int): List = edits + .map { edit -> + edit.copy(replacements = edit.replacements.filter { safetyLevel(it.safety) == level }) + } + .filter { it.replacements.isNotEmpty() } + +class MagoApplyEditAction( + private val edits: List, + private val isApplyAll: Boolean = false, + private val applyAllScope: ApplyAllScope? = null, + private val fixDescription: String? = null +) : IntentionAction, PriorityAction, FileModifier { + + override fun getElementToMakeWritable(currentFile: PsiFile): PsiElement = currentFile + + override fun getFileModifierForPreview(target: PsiFile): FileModifier { + return MagoApplyEditAction(edits, isApplyAll, applyAllScope, fixDescription) + } + override fun getFamilyName() = "Mago" + + override fun getPriority(): PriorityAction.Priority { + return if (isApplyAll) PriorityAction.Priority.LOW else PriorityAction.Priority.HIGH + } + + override fun getText(): String { + if (applyAllScope != null) { + return "Mago: Apply all suggested fixes (${applyAllScope.label})" + } + val maxSafetyValue = edits.flatMap { it.replacements }.maxOfOrNull { safetyLevel(it.safety) } ?: 0 + val safetySuffix = when (maxSafetyValue) { + 2 -> " (unsafe)" + 1 -> " (potentially unsafe)" + else -> "" + } + return when { + !fixDescription.isNullOrBlank() -> "Mago: " + fixDescription.trim() + safetySuffix + isApplyAll -> "Mago: Apply all suggested fixes$safetySuffix" + else -> "Mago: Apply suggested fix$safetySuffix" + } + } + + override fun invoke(project: Project, editor: Editor, file: PsiFile) { + val filePath = file.virtualFile?.path?.let { normalizePath(it) } + val fileName = file.name + val currentFileEdits = edits.filter { editMatchesFile(it, filePath, fileName) } + if (currentFileEdits.isEmpty()) return + val fileText = file.text + val doc = editor.document + val allReplacements = currentFileEdits.flatMap { edit -> + edit.replacements.map { r -> + val startChar = byteOffsetToCharOffset(fileText, r.start) + val endChar = byteOffsetToCharOffset(fileText, r.end) + Triple(startChar, endChar, r.newText) + } + }.sortedByDescending { it.first } + val inPreview = IntentionPreviewUtils.isIntentionPreviewActive() + if (inPreview) { + for ((startChar, endChar, newText) in allReplacements) { + if (startChar in 0..endChar && endChar <= doc.textLength) { + doc.replaceString(startChar, endChar, newText) + } + } + } else { + WriteCommandAction.runWriteCommandAction(project) { + for ((startChar, endChar, newText) in allReplacements) { + if (startChar in 0..endChar && endChar <= doc.textLength) { + doc.replaceString(startChar, endChar, newText) + } + } + PsiDocumentManager.getInstance(project).commitDocument(doc) + file.putUserData(MagoGlobalInspection.MAGO_ANNOTATOR_INFO, null) + MagoHtmlAnnotator.clearProblemCache(file) + val settings = project.getService(MagoProjectConfiguration::class.java) + if (settings.formatAfterFix && settings.formatterEnabled) { + val formatter = MagoExternalFormatter() + if (formatter.activeForFile(file)) { + formatter.format(file, file.textRange, + canChangeWhiteSpacesOnly = false, + keepLineBreaks = false, + enableBulkUpdate = false, + cursorOffset = 0 + ) + } + } + DaemonCodeAnalyzer.getInstance(project).restart(file, "Mago fix applied") + } + } + } + + private fun byteOffsetToCharOffset(text: String, byteOffset: Int): Int { + val bytes = text.toByteArray(StandardCharsets.UTF_8) + if (byteOffset <= 0) return 0 + if (byteOffset >= bytes.size) return text.length + return String(bytes.copyOf(byteOffset), StandardCharsets.UTF_8).length + } + + override fun startInWriteAction() = true + override fun isAvailable(project: Project, editor: Editor, file: PsiFile) = edits.isNotEmpty() +} + +class MagoApplyEditSubmenuAction( + private val mainAction: MagoApplyEditAction, + private val subActions: List +) : IntentionAction, IntentionActionWithOptions, PriorityAction, FileModifier { + override fun getFamilyName() = mainAction.familyName + override fun getText() = mainAction.text + override fun invoke(project: Project, editor: Editor, file: PsiFile) { + mainAction.invoke(project, editor, file) + } + + override fun getOptions(): List { + // Don't duplicate the main entry in the submenu (IntentionOptionsOnly shows only getOptions()) + return subActions.filter { it != mainAction } + } + + override fun getCombiningPolicy(): IntentionActionWithOptions.CombiningPolicy { + return IntentionActionWithOptions.CombiningPolicy.IntentionOptionsOnly + } + override fun getPriority() = mainAction.priority + override fun startInWriteAction() = mainAction.startInWriteAction() + override fun isAvailable(project: Project, editor: Editor, file: PsiFile) = mainAction.isAvailable(project, editor, file) + + override fun getElementToMakeWritable(currentFile: PsiFile): PsiElement = mainAction.getElementToMakeWritable(currentFile) + + override fun getFileModifierForPreview(target: PsiFile): FileModifier? { + val mainCopy = mainAction.getFileModifierForPreview(target) as? MagoApplyEditAction ?: return null + val subCopies = subActions.mapNotNull { (it as? FileModifier)?.getFileModifierForPreview(target) } + if (subCopies.size != subActions.size) return null + return MagoApplyEditSubmenuAction(mainCopy, subCopies.filterIsInstance()) + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoGlobalInspection.kt b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoGlobalInspection.kt index b6d7af4..0a53cf3 100644 --- a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoGlobalInspection.kt +++ b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoGlobalInspection.kt @@ -13,6 +13,6 @@ class MagoGlobalInspection : QualityToolValidationGlobalInspection(), ExternalAn override fun getSharedLocalInspectionTool() = MagoValidationInspection() companion object { - private val MAGO_ANNOTATOR_INFO = Key.create>("ANNOTATOR_INFO_MAGO") + val MAGO_ANNOTATOR_INFO = Key.create>("ANNOTATOR_INFO_MAGO") } } diff --git a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoHtmlAnnotator.kt b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoHtmlAnnotator.kt new file mode 100644 index 0000000..81c29a7 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoHtmlAnnotator.kt @@ -0,0 +1,246 @@ +package com.github.xepozz.mago.qualityTool + +import com.github.xepozz.mago.configuration.MagoProjectConfiguration +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.lang.annotation.AnnotationHolder +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.lang.annotation.ExternalAnnotator +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.util.text.StringUtil +import com.intellij.psi.PsiFile +import com.jetbrains.php.tools.quality.QualityToolMessage + +class MagoHtmlAnnotator : ExternalAnnotator() { + data class CollectedInfo(val file: PsiFile, val projectSettings: MagoProjectConfiguration) + data class AnnotationResult(val problems: List) + + companion object { + private val MAGO_HTML_LAST_PROBLEMS: Key> = Key.create("MAGO_HTML_LAST_PROBLEMS") + + /** Clear cached problems for this file so no fix is offered until the next Mago run. */ + fun clearProblemCache(file: PsiFile) { + file.putUserData(MAGO_HTML_LAST_PROBLEMS, null) + } + } + + override fun collectInformation(file: PsiFile): CollectedInfo? { + val project = file.project + val settings = project.getService(MagoProjectConfiguration::class.java) + if (file.language.id != "PHP") return null + return CollectedInfo(file, settings) + } + + override fun doAnnotate(collectedInfo: CollectedInfo): AnnotationResult { + val file = collectedInfo.file + // Prefer freshly parsed problems provided by the standard pipeline; fall back to last known + val current = + file.getUserData(MagoGlobalInspection.MAGO_ANNOTATOR_INFO)?.filterIsInstance() + + val last = file.getUserData(MAGO_HTML_LAST_PROBLEMS) + val problems = current ?: last ?: emptyList() + + if (current != null) { + file.putUserData(MAGO_HTML_LAST_PROBLEMS, current) + } + + return AnnotationResult(problems) + } + + override fun apply(file: PsiFile, annotationResult: AnnotationResult, holder: AnnotationHolder) { + val problems = annotationResult.problems + if (problems.isEmpty()) return + + // Group problems by their range to avoid overlapping annotations being suppressed by the IDE. + // For missing-return-type, limit to the declaration line only (Mago's span starts at the opening brace line; the function signature is on the next line). + val document = file.viewProvider.document + val groupedProblems = problems.groupBy { problem -> + val range = ReadAction.compute { + if (problem.code == "missing-return-type" && document != null) { + val lineIndex = problem.lineNumber.coerceIn(0, document.lineCount - 1) + val lineStart = document.getLineStartOffset(lineIndex) + val lineEnd = document.getLineEndOffset(lineIndex) + lineStart until lineEnd + } else { + byteRangeToCharRange(file.text, problem.startChar, problem.endChar) + } + } + TextRange(range.first, range.last + 1) + } + + val allFileEdits = problems.flatMap { it.edits } + + // Apply-all per exact safety level (each fixes only that category) + val safeOnlyEdits = filterEditsByExactSafety(allFileEdits, ApplyAllScope.SAFE_ONLY.maxSafetyLevel) + val potentiallyUnsafeOnlyEdits = filterEditsByExactSafety(allFileEdits, ApplyAllScope.POTENTIALLY_UNSAFE.maxSafetyLevel) + val unsafeOnlyEdits = filterEditsByExactSafety(allFileEdits, ApplyAllScope.UNSAFE.maxSafetyLevel) + val applyAllByLevel = listOf( + safeOnlyEdits to ApplyAllScope.SAFE_ONLY, + potentiallyUnsafeOnlyEdits to ApplyAllScope.POTENTIALLY_UNSAFE, + unsafeOnlyEdits to ApplyAllScope.UNSAFE + ).map { (edits, scope) -> + if (edits.isEmpty()) null else MagoApplyEditAction(edits, isApplyAll = true, applyAllScope = scope) + } + val applyAllCountByLevel = listOf( + safeOnlyEdits.sumOf { it.replacements.size }, + potentiallyUnsafeOnlyEdits.sumOf { it.replacements.size }, + unsafeOnlyEdits.sumOf { it.replacements.size } + ) + + for ((textRange, rangeProblems) in groupedProblems) { + val highestSeverityProblem = rangeProblems.maxByOrNull { + when (it.severity) { + QualityToolMessage.Severity.ERROR -> 3 + QualityToolMessage.Severity.WARNING -> 2 + QualityToolMessage.Severity.INTERNAL_ERROR -> 1 + else -> 0 + } + } ?: continue + + val severity = when (highestSeverityProblem.severity) { + QualityToolMessage.Severity.ERROR -> HighlightSeverity.ERROR + QualityToolMessage.Severity.WARNING -> HighlightSeverity.WARNING + else -> HighlightSeverity.WEAK_WARNING + } + + val gutterMessage = if (rangeProblems.size == 1) { + val problem = rangeProblems.first() + "Mago: ${problem.myMessage} [${problem.code}]" + } else { + "Mago: Multiple issues found in this range" + } + + val tooltipMessage = formatGroupedHtmlMessage(rangeProblems) + + val builder = holder.newAnnotation(severity, gutterMessage) + .range(textRange) + .tooltip(tooltipMessage) + + // One parent submenu per safety category (safe, potentially unsafe, unsafe) + val addedSubmenus = mutableSetOf() + for (problem in rangeProblems) { + for (edit in problem.edits) { + val level = edit.maxSafetyLevel() + if (level !in 0..2 || !addedSubmenus.add(level)) continue + + val individualActions = rangeProblems + .flatMap { p -> p.edits.filter { it.maxSafetyLevel() == level }.map { edit -> edit to p } } + .distinctBy { (edit, _) -> edit } + .map { (edit, problem) -> + val description = problem.help.ifBlank { problem.myMessage } + MagoApplyEditAction(listOf(edit), fixDescription = description.ifBlank { null }) + } + val applyAllForLevel = if (applyAllCountByLevel.getOrNull(level)?.let { it > 1 } == true) + applyAllByLevel.getOrNull(level) else null + val actions = individualActions + listOfNotNull(applyAllForLevel) + if (actions.isEmpty()) continue + if (actions.size == 1) { + builder.withFix(actions.single()) + } else { + val mainAction = actions.first() + builder.withFix(MagoApplyEditSubmenuAction(mainAction, actions)) + } + } + } + + // Group ignore actions into a submenu + val addedFixes = mutableSetOf() + val fileText = file.text + for (problem in rangeProblems) { + // skip lint:strict-types — it would insert above () + val editor = FileEditorManager.getInstance(file.project).selectedTextEditor + if (editor != null) { + val functionIgnore = MarkIgnoreFunctionAction(problem.category, problem.code, problem.lineNumber, problemStartOffset) + if (functionIgnore.isAvailable(file.project, editor, file)) { + ignoreOptions.add(functionIgnore) + } + val methodIgnore = MarkIgnoreMethodAction(problem.category, problem.code, problem.lineNumber, problemStartOffset) + if (methodIgnore.isAvailable(file.project, editor, file)) { + ignoreOptions.add(methodIgnore) + } + val classIgnore = MarkIgnoreClassAction(problem.category, problem.code, problem.lineNumber, problemStartOffset) + if (classIgnore.isAvailable(file.project, editor, file)) { + ignoreOptions.add(classIgnore) + } + } + val suppressActions = listOf(lineIgnore) + ignoreOptions + when { + suppressActions.size == 1 -> { + if (addedFixes.add(suppressActions.single().text)) { + builder.withFix(suppressActions.single()) + } + } + suppressActions.size >= 2 -> { + val mainAction = suppressActions.first() + val submenuAction = MagoIgnoreSubmenuAction(problem.category, problem.code, mainAction, suppressActions) + if (addedFixes.add("suppress_submenu_${problem.code}")) { + builder.withFix(submenuAction) + } + } + else -> { + if (addedFixes.add(lineIgnore.text)) { + builder.withFix(lineIgnore) + } + } + } + } + + builder.create() + } + file.putUserData(MAGO_HTML_LAST_PROBLEMS, problems) + } + + private fun formatGroupedHtmlMessage(problems: List): String { + val sb = StringBuilder("") + + problems.forEachIndexed { index, problem -> + if (index > 0) { + sb.append("

") + } + + sb.append("Mago: ").append(escapeAndFormat(problem.myMessage)).append(" [").append(problem.code).append("]") + + if (problem.notes.isNotEmpty()) { + for (note in problem.notes) { + sb.append("
").append(escapeAndFormat(note)) + } + } + + if (problem.help.isNotEmpty()) { + sb.append("

Help: ").append(escapeAndFormat(problem.help)) + } + } + + sb.append("") + return sb.toString() + } + + private fun escapeAndFormat(text: String): String { + var result = StringUtil.escapeXmlEntities(text) + var open = true + while (result.contains("`")) { + result = if (open) { + result.replaceFirst("`", "") + } else { + result.replaceFirst("`", "") + } + open = !open + } + if (!open) result += "" + return result + } + + private fun byteRangeToCharRange(text: String, byteStart: Int, byteEnd: Int): IntRange { + val bytes = text.toByteArray(Charsets.UTF_8) + val charStart = String(bytes.copyOf(byteStart), Charsets.UTF_8).length + val charEnd = String(bytes.copyOf(byteEnd), Charsets.UTF_8).length + return charStart until charEnd + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoJsonMessageHandler.kt b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoJsonMessageHandler.kt index 0223721..d20301a 100644 --- a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoJsonMessageHandler.kt +++ b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoJsonMessageHandler.kt @@ -5,7 +5,7 @@ import com.intellij.openapi.util.io.FileUtil import com.jetbrains.php.tools.quality.QualityToolMessage class MagoJsonMessageHandler { - fun parseJson(line: String): List { + fun parseJson(line: String, category: String = "analysis"): List { // println("JSON: $line") return JsonParser.parseString(line) .apply { if (this == null || this.isJsonNull) return emptyList() } @@ -13,9 +13,31 @@ class MagoJsonMessageHandler { .getAsJsonArray("issues") ?.map { it.asJsonObject } ?.flatMap { issue -> + val code = issue.get("code").asString + val help = issue.get("help")?.asString ?: "" + val notes = issue.getAsJsonArray("notes")?.map { it.asString } ?: emptyList() + val edits = issue.getAsJsonArray("edits")?.map { it.asJsonArray }?.map { editArray -> + val fileId = editArray.get(0).asJsonObject + val replacements = editArray.get(1).asJsonArray.map { it.asJsonObject }.map { replacement -> + val range = replacement.getAsJsonObject("range") + MagoReplacement( + range.get("start").asInt, + range.get("end").asInt, + replacement.get("new_text").asString, + replacement.get("safety").asString + ) + } + val name = fileId.get("name").asString + val path = fileId.get("path").asString + MagoEdit(name, path, replacements) + } ?: emptyList() + issue.getAsJsonArray("annotations") ?.map { it.asJsonObject } - ?.filter { it.get("kind").asString == "Primary" } + ?.filter { + val kind = it.get("kind").asString + kind == "Primary" || (code == "type-inspection" && kind == "Secondary") + } ?.mapNotNull { annotation -> val span = annotation.getAsJsonObject("span") ?: return@mapNotNull null val filePath = (span.getAsJsonObject("file_id") @@ -25,16 +47,25 @@ class MagoJsonMessageHandler { ?.let { FileUtil.toSystemIndependentName(it) } ?: return@mapNotNull null) + val kind = annotation.get("kind").asString + val message = if (kind == "Primary") { + issue.get("message").asString.trimEnd('.') + } else { + annotation.get("message").asString.trimEnd('.') + } + MagoProblemDescription( levelToSeverity(issue.get("level").asString), span.getAsJsonObject("start")?.get("line")?.asInt ?: return@mapNotNull null, span.getAsJsonObject("start")?.get("offset")?.asInt ?: return@mapNotNull null, span.getAsJsonObject("end")?.get("offset")?.asInt ?: return@mapNotNull null, - "Mago: ${issue.get("message").asString.trimEnd('.')} [${issue.get("code").asString}]", + message, filePath, - issue.get("code")?.asString ?: "", - issue.get("help")?.asString ?: "", - issue.getAsJsonArray("notes")?.map { it.asString } ?: emptyList(), + code, + category, + help, + notes, + edits, ) } ?: emptyList() @@ -45,6 +76,8 @@ class MagoJsonMessageHandler { fun levelToSeverity(level: String?) = when (level) { "Error" -> QualityToolMessage.Severity.ERROR "Warning" -> QualityToolMessage.Severity.WARNING - else -> null + "Help" -> QualityToolMessage.Severity.WARNING + "Note" -> QualityToolMessage.Severity.WARNING + else -> QualityToolMessage.Severity.INTERNAL_ERROR } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoMessageProcessor.kt b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoMessageProcessor.kt index 8bb7393..62f6461 100644 --- a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoMessageProcessor.kt +++ b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoMessageProcessor.kt @@ -1,8 +1,8 @@ package com.github.xepozz.mago.qualityTool +import com.github.xepozz.mago.configuration.MagoProjectConfiguration +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer import com.intellij.codeHighlighting.HighlightDisplayLevel -import com.intellij.openapi.application.ReadAction -import com.intellij.openapi.util.TextRange import com.jetbrains.php.tools.quality.QualityToolAnnotatorInfo import com.jetbrains.php.tools.quality.QualityToolExecutionException import com.jetbrains.php.tools.quality.QualityToolMessage @@ -20,63 +20,121 @@ class MagoMessageProcessor(private val info: QualityToolAnnotatorInfo<*>) : Qual override fun parseLine(line: String) { val outputLine = line.trim() -// println("parseLine $outputLine for $info") - if (!startParsing) { - if (!outputLine.startsWith("{")) { - errorBuffer.append(outputLine) - return - } + if (outputLine.startsWith("{")) { startParsing = true } - buffer.append(outputLine) + if (startParsing) { + buffer.append(outputLine) + } else if (outputLine.isNotEmpty()) { + errorBuffer.append(outputLine).append("\n") + } } override fun severityToDisplayLevel(severity: QualityToolMessage.Severity) = HighlightDisplayLevel.find(severity.name) override fun done() { -// println("done: $buffer") - MagoJsonMessageHandler() - .parseJson(buffer.toString()) -// .apply { -// thisLogger().info("files: ${map { it.file }}, current: ${file.virtualFile.canonicalPath}") -// } -// .filter { -// val currentFilePath = file.virtualFile.canonicalPath ?: return@filter false -// -// thisLogger().info("compare ${it.file} ends with $currentFilePath") -// it.file.endsWith(currentFilePath) -// } - .map { problem -> - /** - * temporary convert to bytes-offset to chars offset - */ - val range = ReadAction.compute { byteRangeToCharRange(file.text, problem.startChar, problem.endChar) } - val textRange = TextRange(range.first, range.last + 1) -// val textRange = TextRange(problem.startChar, problem.endChar) - - QualityToolMessage( - this, - textRange, - problem.severity, - problem.message, - MagoReformatFileAction(info.project), - MarkIgnoreAction(problem.code, problem.lineNumber), - ) + val fullBuffer = buffer.toString() + val jsonOutputs = mutableListOf() + var depth = 0 + var currentJson = StringBuilder() + + for (char in fullBuffer) { + if (char == '{') { + depth++ + } + if (depth > 0) { + currentJson.append(char) } - .apply { - if (isEmpty() && errorBuffer.isNotEmpty()) { - throw QualityToolExecutionException("Caught errors while running Mago: $errorBuffer") + if (char == '}') { + depth-- + if (depth == 0) { + val json = currentJson.toString() + jsonOutputs.add(json) + currentJson = StringBuilder() } } - .forEach { addMessage(it) } + } + + val allProblems = mutableListOf() + val handler = MagoJsonMessageHandler() + for (json in jsonOutputs) { + try { + val parsed = handler.parseJson(json) + allProblems.addAll(parsed) + } catch (_: Exception) { + // Ignore parsing errors for individual parts if they are not valid JSON + } + } + + val settings = MagoProjectConfiguration.getInstance(info.project) + val configuration = settings.findConfigurationById(settings.selectedConfigurationId, info.project) + val filePath = info.psiFile?.virtualFile?.path + + if (configuration != null && filePath != null) { + val magoExecutable = configuration.toolPath + + if (settings.linterEnabled) { + val lintOptions = MagoAnnotatorProxy.getLintOptions(settings, info.project, filePath) + val lintProblems = mutableListOf() + runExtraTool(magoExecutable, lintOptions, lintProblems, handler, "lint") + allProblems.addAll(lintProblems) + } + + if (settings.guardEnabled) { + val guardOptions = MagoAnnotatorProxy.getGuardOptions(settings, info.project, filePath) + val guardProblems = mutableListOf() + runExtraTool(magoExecutable, guardOptions, guardProblems, handler, "guard") + allProblems.addAll(guardProblems) + } + } + + val finalProblems = allProblems.distinctBy { + "${it.startChar}-${it.endChar}-${it.code}-${it.myMessage}" + } + + // Save problems to the file's user data so that MagoHtmlAnnotator can access them + info.psiFile?.let { psiFile -> + psiFile.putUserData(MagoGlobalInspection.MAGO_ANNOTATOR_INFO, finalProblems) + + // Restart daemon to force MagoHtmlAnnotator to pick up new data + DaemonCodeAnalyzer.getInstance(info.project).restart(psiFile, "Mago analysis results updated") + } + + if (finalProblems.isEmpty() && errorBuffer.isNotEmpty()) { + throw QualityToolExecutionException("Caught errors while running Mago: $errorBuffer") + } } - private fun byteRangeToCharRange(text: String, byteStart: Int, byteEnd: Int): IntRange { - val bytes = text.toByteArray(Charsets.UTF_8) - val charStart = String(bytes.copyOf(byteStart), Charsets.UTF_8).length - val charEnd = String(bytes.copyOf(byteEnd), Charsets.UTF_8).length - return charStart until charEnd + private fun runExtraTool( + magoExecutable: String, + options: List, + allProblems: MutableList, + handler: MagoJsonMessageHandler, + category: String + ) { + try { + @Suppress("DialogTitleCapitalization") val title = "Running Mago..." + + val output = com.jetbrains.php.tools.quality.QualityToolProcessCreator.getToolOutput( + info.project, + null, + magoExecutable, + 1, + title, + null, + *options.toTypedArray() + ) + + if (output.exitCode == 0 || output.stdout.isNotEmpty()) { + val json = output.stdout + if (json.isNotEmpty()) { + allProblems.addAll(handler.parseJson(json, category)) + } + } + } catch (_: Exception) { + // Ignore errors for individual tools + } } } diff --git a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoProblemDescription.kt b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoProblemDescription.kt index 0cb94bf..261de87 100644 --- a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoProblemDescription.kt +++ b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoProblemDescription.kt @@ -11,8 +11,10 @@ class MagoProblemDescription( var myMessage: String, val myFile: String, val code: String, + val category: String, val help: String, val notes: List, + val edits: List = emptyList(), ) : QualityToolXmlMessageProcessor.ProblemDescription( severity, lineNumber, @@ -20,3 +22,16 @@ class MagoProblemDescription( myMessage, myFile, ) + +data class MagoEdit( + val name: String, + val path: String, + val replacements: List +) + +data class MagoReplacement( + val start: Int, + val end: Int, + val newText: String, + val safety: String +) diff --git a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoReformatFileAction.kt b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoReformatFileAction.kt deleted file mode 100644 index 461d0ef..0000000 --- a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoReformatFileAction.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.github.xepozz.mago.qualityTool - -import com.github.xepozz.mago.MagoBundle -import com.github.xepozz.mago.configuration.MagoConfigurationBaseManager -import com.github.xepozz.mago.formatter.MagoReformatFile -import com.intellij.openapi.project.Project -import com.intellij.profile.codeInspection.InspectionProjectProfileManager -import com.intellij.psi.PsiFile -import com.jetbrains.php.tools.quality.QualityToolReformatFileAction - -class MagoReformatFileAction(val project: Project) : - QualityToolReformatFileAction(MagoReformatFile(project)) { - override fun getFamilyName() = MagoConfigurationBaseManager.MAGO - override fun getText() = MagoBundle.message("quality.tool.mago.quick.fix.text") - - override fun getInspection( - project: Project, - file: PsiFile, - ) = InspectionProjectProfileManager.getInstance(project) - .currentProfile - .getUnwrappedTool(MagoValidationInspection().shortName, file) as? MagoValidationInspection -} diff --git a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MarkIgnoreAction.kt b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MarkIgnoreAction.kt index 5c7025d..8dd58e3 100644 --- a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MarkIgnoreAction.kt +++ b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MarkIgnoreAction.kt @@ -1,24 +1,195 @@ package com.github.xepozz.mago.qualityTool +import com.intellij.codeInsight.intention.FileModifier import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.codeInsight.intention.IntentionActionWithOptions +import com.intellij.codeInsight.intention.PriorityAction import com.intellij.openapi.editor.Editor -import com.intellij.openapi.editor.LogicalPosition import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile +import com.intellij.psi.util.PsiTreeUtil +import com.jetbrains.php.lang.psi.elements.PhpClass +import com.jetbrains.php.lang.psi.elements.Function +import com.jetbrains.php.lang.psi.elements.Method -class MarkIgnoreAction(val code: String, val line: Int) : IntentionAction { +class MagoIgnoreSubmenuAction( + private val category: String, + private val code: String, + private val mainAction: IntentionAction, + private val actions: List +) : IntentionAction, IntentionActionWithOptions, PriorityAction, FileModifier { + + override fun getElementToMakeWritable(currentFile: PsiFile): PsiElement? = + (mainAction as? FileModifier)?.getElementToMakeWritable(currentFile) ?: currentFile + + override fun getFileModifierForPreview(target: PsiFile): FileModifier? { + val mainCopy = (mainAction as? FileModifier)?.getFileModifierForPreview(target) as? IntentionAction ?: return null + val actionCopies = actions.mapNotNull { (it as? FileModifier)?.getFileModifierForPreview(target) as? IntentionAction } + if (actionCopies.size != actions.size) return null + return MagoIgnoreSubmenuAction(category, code, mainCopy, actionCopies) + } override fun getFamilyName() = "Mago" - override fun getText() = "Mark @mago-ignore `${code}`" + override fun getText() = "Mago: Suppress `${category}:${code}`" + override fun invoke(project: Project, editor: Editor, file: PsiFile) { + mainAction.invoke(project, editor, file) + } - override fun invoke(project: Project, editor: Editor, file: PsiFile?) { - editor.document.insertString( - editor.logicalPositionToOffset(LogicalPosition(line, 0)), - "/** @mago-ignore analysis:${code} */\n" - ) -// PsiDocumentManager.getInstance(project).commitDocument(editor.document) -// DaemonCodeAnalyzer.getInstance(project).restart() + override fun getOptions(): List = actions + + override fun getCombiningPolicy(): IntentionActionWithOptions.CombiningPolicy { + return IntentionActionWithOptions.CombiningPolicy.IntentionOptionsOnly } + override fun getPriority() = PriorityAction.Priority.NORMAL + override fun isAvailable(project: Project, editor: Editor, file: PsiFile) = mainAction.isAvailable(project, editor, file) + override fun startInWriteAction() = mainAction.startInWriteAction() +} +sealed class MagoIgnoreAction( + val category: String, + val code: String, + val line: Int, + /** When set, use this offset to find the PSI element (underlined range start); otherwise use line. */ + private val problemStartOffset: Int? = null +) : IntentionAction, PriorityAction { + override fun getFamilyName() = "Mago" override fun startInWriteAction() = true override fun isAvailable(project: Project, editor: Editor, file: PsiFile) = true -} \ No newline at end of file + override fun getPriority() = PriorityAction.Priority.NORMAL + + /** Pattern: /** @mago-ignore category:code-or-codes */ — only the same category is merged. */ + private val MAGO_IGNORE_LINE = Regex("""^\s*/\*\*\s*@mago-ignore\s+([\w-]+):([^*]+)\s*\*/\s*$""") + + /** Mago reports 1-based line numbers; Document uses 0-based line index. */ + protected fun lineToIndex(document: com.intellij.openapi.editor.Document): Int = + (line - 1).coerceIn(0, document.lineCount - 1) + + /** Offset at which to find the element (problem range start, or line start if not set). */ + protected fun getElementOffset(document: com.intellij.openapi.editor.Document): Int = + problemStartOffset?.coerceIn(0, (document.textLength - 1).coerceAtLeast(0)) + ?: document.getLineStartOffset(lineToIndex(document)) + + protected fun insertIgnore(document: com.intellij.openapi.editor.Document, offset: Int, indentation: String) { + val lineNum = document.getLineNumber(offset) + if (lineNum > 0) { + val prevLineNum = lineNum - 1 + val prevStart = document.getLineStartOffset(prevLineNum) + val prevEnd = document.getLineEndOffset(prevLineNum) + val prevLine = document.charsSequence.subSequence(prevStart, prevEnd).toString() + val match = MAGO_IGNORE_LINE.find(prevLine.trim()) + if (match != null && match.groupValues[1] == category) { + val existingCodes = match.groupValues[2].split(',').map { it.trim() }.filter { it.isNotEmpty() }.toMutableSet() + existingCodes.add(code) + val prevIndent = prevLine.takeWhile { it.isWhitespace() } + val newComment = "${prevIndent}/** @mago-ignore ${category}:${existingCodes.sorted().joinToString(",")} */" + document.replaceString(prevStart, prevEnd, newComment) + return + } + } + document.insertString( + offset, + "${indentation}/** @mago-ignore ${category}:${code} */\n" + ) + } + + protected fun getIndentationAt(document: com.intellij.openapi.editor.Document, offset: Int): String { + val lineNum = document.getLineNumber(offset) + val lineStart = document.getLineStartOffset(lineNum) + val lineEnd = document.getLineEndOffset(lineNum) + val lineText = document.charsSequence.subSequence(lineStart, lineEnd) + return lineText.takeWhile { it.isWhitespace() }.toString() + } +} + +class MarkIgnoreAction(category: String, code: String, line: Int, problemStartOffset: Int? = null) : MagoIgnoreAction(category, code, line, problemStartOffset) { + override fun getText() = "Mago: Suppress `${category}:${code}` for statement" + + override fun invoke(project: Project, editor: Editor, file: PsiFile?) { + val document = editor.document + // Use the line that contains the problem offset so we don't insert on a blank line (wrong indentation + extra newline) + val insertOffset = getElementOffset(document).let { offset -> + val safeOffset = offset.coerceIn(0, (document.textLength - 1).coerceAtLeast(0)) + document.getLineStartOffset(document.getLineNumber(safeOffset)) + } + val indentation = getIndentationAt(document, insertOffset) + insertIgnore(document, insertOffset, indentation) + } +} + +class MarkIgnoreFunctionAction(category: String, code: String, line: Int, problemStartOffset: Int? = null) : MagoIgnoreAction(category, code, line, problemStartOffset) { + override fun getText(): String { + return "Mago: Suppress `${category}:${code}` for function" + } + + override fun isAvailable(project: Project, editor: Editor, file: PsiFile): Boolean { + val document = editor.document + val offset = getElementOffset(document) + val elementAt = file.findElementAt(offset) + val phpFunction = PsiTreeUtil.getParentOfType(elementAt, Function::class.java) + return phpFunction != null && phpFunction !is Method + } + + override fun invoke(project: Project, editor: Editor, file: PsiFile?) { + if (file == null) return + val document = editor.document + val offset = getElementOffset(document) + val elementAt = file.findElementAt(offset) + val phpFunction = PsiTreeUtil.getParentOfType(elementAt, Function::class.java) ?: return + val elementStart = phpFunction.textRange.startOffset + val lineStart = document.getLineStartOffset(document.getLineNumber(elementStart)) + val indentation = getIndentationAt(document, lineStart) + insertIgnore(document, lineStart, indentation) + } +} + +class MarkIgnoreMethodAction(category: String, code: String, line: Int, problemStartOffset: Int? = null) : MagoIgnoreAction(category, code, line, problemStartOffset) { + override fun getText(): String { + return "Mago: Suppress `${category}:${code}` for method" + } + + override fun isAvailable(project: Project, editor: Editor, file: PsiFile): Boolean { + val document = editor.document + val offset = getElementOffset(document) + val elementAt = file.findElementAt(offset) + val phpMethod = PsiTreeUtil.getParentOfType(elementAt, Method::class.java) + return phpMethod != null + } + + override fun invoke(project: Project, editor: Editor, file: PsiFile?) { + if (file == null) return + val document = editor.document + val offset = getElementOffset(document) + val elementAt = file.findElementAt(offset) + val phpMethod = PsiTreeUtil.getParentOfType(elementAt, Method::class.java) ?: return + val elementStart = phpMethod.textRange.startOffset + val lineStart = document.getLineStartOffset(document.getLineNumber(elementStart)) + val indentation = getIndentationAt(document, lineStart) + insertIgnore(document, lineStart, indentation) + } +} + +class MarkIgnoreClassAction(category: String, code: String, line: Int, problemStartOffset: Int? = null) : MagoIgnoreAction(category, code, line, problemStartOffset) { + override fun getText(): String { + return "Mago: Suppress `${category}:${code}` for class" + } + + override fun isAvailable(project: Project, editor: Editor, file: PsiFile): Boolean { + val document = editor.document + val offset = getElementOffset(document) + val elementAt = file.findElementAt(offset) + val phpClass = PsiTreeUtil.getParentOfType(elementAt, PhpClass::class.java) + return phpClass != null + } + + override fun invoke(project: Project, editor: Editor, file: PsiFile?) { + if (file == null) return + val document = editor.document + val offset = getElementOffset(document) + val elementAt = file.findElementAt(offset) + val phpClass = PsiTreeUtil.getParentOfType(elementAt, PhpClass::class.java) ?: return + val elementStart = phpClass.textRange.startOffset + val lineStart = document.getLineStartOffset(document.getLineNumber(elementStart)) + val indentation = getIndentationAt(document, lineStart) + insertIgnore(document, lineStart, indentation) + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 86be235..d8247f9 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -60,6 +60,9 @@ + diff --git a/src/main/resources/messages/MagoBundle.properties b/src/main/resources/messages/MagoBundle.properties index 979a43d..0718935 100644 --- a/src/main/resources/messages/MagoBundle.properties +++ b/src/main/resources/messages/MagoBundle.properties @@ -2,14 +2,15 @@ configurable.quality.tool.php.mago=Mago inspection.mago.display.name=Mago validation inspection.mago.global.display.name=Mago Global validation quality.tool.mago=Mago -quality.tool.mago.quick.fix.text=Mago: fix the whole file quality.tool.settings.link.inspection={0} inspections settings.enabled=Enabled settings.analyzer.title=Analyzer settings.linter.title=Linter settings.formatter.title=Formatter +settings.formatter.formatAfterFix=Format after fix +settings.formatter.formatAfterFix.comment=Add --format-after-fix when running \"Apply all suggested fixes\" (mago lint --fix). settings.options.title=Options settings.guard.title=Guard -notification.group.mago=Mago \ No newline at end of file +notification.group.mago=Mago