From 75d45d599db30dc47e33d28618253c1077bbf50c Mon Sep 17 00:00:00 2001 From: WalterWoshid Date: Wed, 11 Feb 2026 13:16:24 +0000 Subject: [PATCH 1/3] chore: target PhpStorm (PS) Running buildPlugin uses PhpStorm instead of IntelliJ --- gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 3c36826..b2ae8aa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,14 +10,14 @@ 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 +platformType = PS platformVersion = 2025.1.1 # 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 platformPlugins = org.toml.lang:251.23774.216,com.jetbrains.php:251.23774.16,org.jetbrains.plugins.phpstorm-remote-interpreter:251.23774.16,org.jetbrains.plugins.phpstorm-docker:251.23774.16,com.github.xepozz.metastorm:2025.1.29,com.jetbrains.hackathon.indices.viewer:1.30 # Example: platformBundledPlugins = com.intellij.java -platformBundledPlugins = com.intellij.modules.json +platformBundledPlugins = com.intellij.modules.json,Docker # Example: platformBundledModules = intellij.spellchecker platformBundledModules = From 337154b07ac40be0cbac70d0bbb5809a735fdf1e Mon Sep 17 00:00:00 2001 From: WalterWoshid Date: Wed, 11 Feb 2026 13:16:36 +0000 Subject: [PATCH 2/3] feat: add linter and guard, apply fixes and suppress by scope - Run mago lint and guard with analyze; merge results into annotations - Apply single fix or apply-all by safety (safe / potentially unsafe / unsafe) - 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 - Improved `mago.toml` logic/completions --- .../xepozz/mago/analysis/MagoCliOptions.kt | 211 +++++++++ .../mago/analysis/MagoJsonMessageHandler.kt | 118 +++++ .../mago/analysis/MagoProblemsCollector.kt | 304 ++++++++++++ .../mago/annotator/MagoExternalAnnotator.kt | 443 ++++++++++++++++++ .../MagoComposerAutoDetectActivity.kt | 85 ++++ .../mago/composer/MagoComposerConfig.kt | 25 - .../mago/composer/MagoOpenSettingsProvider.kt | 8 +- .../mago/config/MagoConfigSchemaUtil.kt | 115 +++++ .../xepozz/mago/config/MagoSchemaHolder.kt | 55 ++- .../MagoTomlJsonSchemaProviderFactory.kt | 5 +- .../reference/ConfigCompletionContributor.kt | 143 +++++- .../mago/config/reference/ConfigStructure.kt | 34 +- .../reference/FileSetReferenceContributor.kt | 4 +- .../mago/config/reference/LayerReference.kt | 4 +- .../config/reference/LayersListReference.kt | 5 +- .../reference/LayersReferenceContributor.kt | 3 +- .../mago/configuration/MagoConfigurable.kt | 241 +++++++--- .../configuration/MagoConfigurableForm.kt | 3 +- .../configuration/MagoProjectConfiguration.kt | 34 +- .../configuration/MagoWorkspaceMapping.kt | 12 + .../remote/MagoRemoteConfigurationProvider.kt | 7 +- .../xepozz/mago/execution/LocalMagoRunner.kt | 55 +++ .../xepozz/mago/execution/MagoRunner.kt | 37 ++ .../execution/RemoteInterpreterMagoRunner.kt | 89 ++++ .../xepozz/mago/file/MagoTextFileType.kt | 20 +- .../xepozz/mago/file/MagoTomlFileType.kt | 19 +- .../xepozz/mago/formatter/Directives.kt | 2 +- .../mago/formatter/MagoExternalFormatter.kt | 134 ++++-- .../xepozz/mago/formatter/MagoReformatFile.kt | 48 -- .../mago/formatter/MagoReformatFileAction.kt | 12 - .../intentions/MagoNavigateToRelatedAction.kt | 51 ++ .../MagoRemoveRedundantFileAction.kt | 32 ++ .../mago/intentions/apply/ApplyAllScope.kt | 11 + .../mago/intentions/apply/ApplyEditUtils.kt | 35 ++ .../intentions/apply/MagoApplyEditAction.kt | 111 +++++ .../apply/MagoApplyEditSubmenuAction.kt | 47 ++ .../suppress/MagoClassSuppressAction.kt | 30 ++ .../suppress/MagoFunctionSuppressAction.kt | 54 +++ .../intentions/suppress/MagoIgnoreAction.kt | 429 +++++++++++++++++ .../MagoIgnoreFirstAvailableAction.kt | 40 ++ .../suppress/MagoIgnoreSubmenuAction.kt | 77 +++ .../suppress/MagoMethodSuppressAction.kt | 30 ++ .../suppress/MagoStatementSuppressAction.kt | 39 ++ .../kotlin/com/github/xepozz/mago/mixin.kt | 37 +- .../mago/model/MagoProblemDescription.kt | 44 ++ .../qualityTool/MagoAddToIgnoredAction.kt | 12 - .../mago/qualityTool/MagoAnnotatorProxy.kt | 145 +----- .../mago/qualityTool/MagoGlobalInspection.kt | 2 +- .../qualityTool/MagoJsonMessageHandler.kt | 50 -- .../mago/qualityTool/MagoMessageProcessor.kt | 82 ---- .../qualityTool/MagoProblemDescription.kt | 22 - .../mago/qualityTool/MagoQualityToolType.kt | 3 +- .../qualityTool/MagoReformatFileAction.kt | 22 - .../qualityTool/MagoValidationInspection.kt | 5 +- .../mago/qualityTool/MarkIgnoreAction.kt | 24 - .../xepozz/mago/utils/NotificationsUtil.kt | 12 +- src/main/resources/META-INF/language-toml.xml | 4 +- ...nguage-json.xml => mago-optional-json.xml} | 1 - src/main/resources/META-INF/plugin.xml | 41 +- .../resources/messages/MagoBundle.properties | 129 ++++- .../MagoCliOptionsTest.kt} | 42 +- .../analysis/MagoJsonMessageHandlerTest.kt | 436 +++++++++++++++++ .../annotator/MagoAnnotationFunctionalTest.kt | 185 ++++++++ .../magoFunctional/mixedArgumentSubstr/1.php | 7 + .../mixedArgumentSubstr/mago-output.json | 32 ++ .../magoFunctional/multipleErrors/input.php | 7 + .../multipleErrors/mago-output.json | 109 +++++ .../multipleErrorsDuplicated/input.php | 8 + .../multipleErrorsDuplicated/mago-output.json | 130 +++++ 69 files changed, 4184 insertions(+), 668 deletions(-) create mode 100644 src/main/kotlin/com/github/xepozz/mago/analysis/MagoCliOptions.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/analysis/MagoJsonMessageHandler.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/analysis/MagoProblemsCollector.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/annotator/MagoExternalAnnotator.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/composer/MagoComposerAutoDetectActivity.kt delete mode 100644 src/main/kotlin/com/github/xepozz/mago/composer/MagoComposerConfig.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/config/MagoConfigSchemaUtil.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/configuration/MagoWorkspaceMapping.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/execution/LocalMagoRunner.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/execution/MagoRunner.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/execution/RemoteInterpreterMagoRunner.kt delete mode 100644 src/main/kotlin/com/github/xepozz/mago/formatter/MagoReformatFile.kt delete mode 100644 src/main/kotlin/com/github/xepozz/mago/formatter/MagoReformatFileAction.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/intentions/MagoNavigateToRelatedAction.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/intentions/MagoRemoveRedundantFileAction.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/intentions/apply/ApplyAllScope.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/intentions/apply/ApplyEditUtils.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/intentions/apply/MagoApplyEditAction.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/intentions/apply/MagoApplyEditSubmenuAction.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoClassSuppressAction.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoFunctionSuppressAction.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoIgnoreAction.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoIgnoreFirstAvailableAction.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoIgnoreSubmenuAction.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoMethodSuppressAction.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoStatementSuppressAction.kt create mode 100644 src/main/kotlin/com/github/xepozz/mago/model/MagoProblemDescription.kt delete mode 100644 src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoAddToIgnoredAction.kt delete mode 100644 src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoJsonMessageHandler.kt delete mode 100644 src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoMessageProcessor.kt delete mode 100644 src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoProblemDescription.kt delete mode 100644 src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoReformatFileAction.kt delete mode 100644 src/main/kotlin/com/github/xepozz/mago/qualityTool/MarkIgnoreAction.kt rename src/main/resources/META-INF/{language-json.xml => mago-optional-json.xml} (91%) rename src/test/kotlin/com/github/xepozz/mago/{qualityTool/MagoAnnotatorProxyTest.kt => analysis/MagoCliOptionsTest.kt} (51%) create mode 100644 src/test/kotlin/com/github/xepozz/mago/analysis/MagoJsonMessageHandlerTest.kt create mode 100644 src/test/kotlin/com/github/xepozz/mago/annotator/MagoAnnotationFunctionalTest.kt create mode 100644 src/test/testData/magoFunctional/mixedArgumentSubstr/1.php create mode 100644 src/test/testData/magoFunctional/mixedArgumentSubstr/mago-output.json create mode 100644 src/test/testData/magoFunctional/multipleErrors/input.php create mode 100644 src/test/testData/magoFunctional/multipleErrors/mago-output.json create mode 100644 src/test/testData/magoFunctional/multipleErrorsDuplicated/input.php create mode 100644 src/test/testData/magoFunctional/multipleErrorsDuplicated/mago-output.json diff --git a/src/main/kotlin/com/github/xepozz/mago/analysis/MagoCliOptions.kt b/src/main/kotlin/com/github/xepozz/mago/analysis/MagoCliOptions.kt new file mode 100644 index 0000000..2f69fdf --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/analysis/MagoCliOptions.kt @@ -0,0 +1,211 @@ +package com.github.xepozz.mago.analysis + +import com.github.xepozz.mago.configuration.MagoProjectConfiguration +import com.github.xepozz.mago.findVirtualFile +import com.github.xepozz.mago.normalizePath +import com.github.xepozz.mago.toPathForExecution +import com.intellij.execution.configurations.ParametersList +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.module.ModuleUtilCore +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir +import com.intellij.openapi.project.rootManager +import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.vfs.VirtualFile + +object MagoCliOptions { + data class ResolvedWorkspace(val workspaceDir: VirtualFile, val configFile: String) + + fun getAnalyzeOptions(settings: MagoProjectConfiguration, project: Project, filePath: String) = + buildOptionsForSingleFile(settings, project, filePath, "analyze", settings.analyzeAdditionalParameters) + + fun getLintOptions(settings: MagoProjectConfiguration, project: Project, filePath: String) = + buildOptionsForSingleFile(settings, project, filePath, "lint", settings.lintAdditionalParameters) + + fun getGuardOptions(settings: MagoProjectConfiguration, project: Project, filePath: String) = + buildOptionsForSingleFile(settings, project, filePath, "guard", settings.guardAdditionalParameters) + + fun getAnalyzeOptionsStdin(settings: MagoProjectConfiguration, project: Project, originalFilePath: String) = + buildOptionsForStdin(settings, project, originalFilePath, "analyze", settings.analyzeAdditionalParameters) + + fun getLintOptionsStdin(settings: MagoProjectConfiguration, project: Project, originalFilePath: String) = + buildOptionsForStdin(settings, project, originalFilePath, "lint", settings.lintAdditionalParameters) + + fun getGuardOptionsStdin(settings: MagoProjectConfiguration, project: Project, originalFilePath: String) = + buildOptionsForStdin(settings, project, originalFilePath, "guard", settings.guardAdditionalParameters) + + fun getFormatOptions(settings: MagoProjectConfiguration, project: Project, files: Collection) = buildList { + val resolved = resolveForFile(project, settings, files.firstOrNull() ?: "") + addWorkspace(resolved.workspaceDir) + addConfig(resolved.configFile) + + add("fmt") + addAll(files.map { toWorkspaceRelativePath(resolved.workspaceDir, it) }) + }.plus(ParametersList.parse(settings.formatAdditionalParameters)) + + private fun buildOptionsForSingleFile( + settings: MagoProjectConfiguration, + project: Project, + filePath: String, + command: String, + additionalParameters: String, + ) = buildList { + val resolved = resolveForFile(project, settings, filePath) + addWorkspace(resolved.workspaceDir) + addConfig(resolved.configFile) + + add(command) + add(toWorkspaceRelativePath(resolved.workspaceDir, filePath)) + add("--reporting-format=json") + addAll(ParametersList.parse(additionalParameters)) + } + + private fun buildOptionsForStdin( + settings: MagoProjectConfiguration, + project: Project, + originalFilePath: String, + command: String, + additionalParameters: String, + ) = buildList { + val resolved = resolveForFile(project, settings, originalFilePath) + addWorkspace(resolved.workspaceDir) + addConfig(resolved.configFile) + + add(command) + add(toWorkspaceRelativePath(resolved.workspaceDir, originalFilePath)) + add("--stdin-input") + add("--reporting-format=json") + addAll(ParametersList.parse(additionalParameters)) + } + + /** + * Resolves the correct workspace directory and config file for [filePath]. + * + * 1. Check explicit workspace mappings (longest prefix match wins). + * 2. Fall back to auto-detected workspace + the global [MagoProjectConfiguration.configurationFile]. + */ + fun resolveForFile(project: Project, settings: MagoProjectConfiguration, filePath: String): ResolvedWorkspace { + val normalizedPath = filePath.normalizePath() + + val mapping = settings.workspaceMappings + .filter { it.workspace.isNotBlank() } + .filter { + val wsPath = it.workspace.normalizePath().removeSuffix("/") + normalizedPath.startsWith("$wsPath/") || normalizedPath == wsPath + } + .maxByOrNull { it.workspace.length } + + if (mapping != null) { + val wsFile = mapping.workspace.findVirtualFile() + if (wsFile != null) { + return ResolvedWorkspace(wsFile, mapping.configFile) + } + } + + val workspace = findWorkspace(project, filePath) + val defaultConfig = settings.configurationFile + val configToUse = when { + defaultConfig.isBlank() -> "" + else -> { + val configParent = defaultConfig.normalizePath().trimEnd('/').let { p -> + val lastSlash = p.lastIndexOf('/') + if (lastSlash <= 0) p else p.substring(0, lastSlash) + } + if (configParent.isNotEmpty() && + (normalizedPath == configParent || normalizedPath.startsWith("$configParent/")) + ) + defaultConfig + else + "" + // File is not under the default config's project; leave config empty so Mago + // discovers mago.toml from the workspace root (e.g.: core's own config). + } + } + return ResolvedWorkspace(workspace, configToUse) + } + + fun findWorkspace(project: Project, filePath: String?): VirtualFile { + if (filePath != null) { + val normalizedFilePath = filePath.normalizePath() + + val file = filePath.findVirtualFile() + if (file != null) { + val closestRoot = ReadAction.compute { + val module = ModuleUtilCore.findModuleForFile(file, project) + val candidateRoots = mutableListOf() + + if (module != null) { + candidateRoots.addAll(module.rootManager.contentRoots) + } + + ProjectFileIndex.getInstance(project).getContentRootForFile(file) + ?.let { candidateRoots.add(it) } + + candidateRoots + .filter { + normalizedFilePath.startsWith( + prefix = it.path.normalizePath().removeSuffix("/") + "/" + ) + } + .maxByOrNull { it.path.length } + } + if (closestRoot != null) return closestRoot + } + + // Scan ALL modules' content roots — handles temp files and multi-attached + // projects where the file index doesn't know about the file. + val allModulesMatch = ReadAction.compute { + ModuleManager.getInstance(project).modules + .flatMap { it.rootManager.contentRoots.toList() } + .filter { + val rootPath = it.path.normalizePath().removeSuffix("/") + normalizedFilePath.startsWith("$rootPath/") + } + .maxByOrNull { it.path.length } + } + if (allModulesMatch != null) return allModulesMatch + } + + project.guessProjectDir()?.let { return it } + + val basePath = project.basePath + if (!basePath.isNullOrBlank()) { + basePath.findVirtualFile()?.let { return it } + } + + return "/".findVirtualFile()!! + } + + private fun toWorkspaceRelativePath(workspace: VirtualFile, absoluteFilePath: String): String = + toRelativePath(workspace.path, absoluteFilePath) + + internal fun toRelativePath(basePath: String, absoluteFilePath: String): String { + val bp = basePath.normalizePath() + val afp = absoluteFilePath.normalizePath() + val relative = FileUtil.getRelativePath(bp, afp, '/') + return ensureMagoPath(relative ?: afp) + } + + internal fun ensureMagoPath(path: String): String = when { + path.isEmpty() -> path + FileUtil.isAbsolute(path) || isWindowsAbsolute(path) || path.startsWith('\\') -> path + path.startsWith("./") || path.startsWith(".\\") -> path + else -> "./$path" + } + + private fun isWindowsAbsolute(path: String): Boolean = + path.length >= 2 && path[0].isLetter() && path[1] == ':' + + private fun MutableList.addWorkspace(workspace: VirtualFile) { + val projectPath = workspace.path.toPathForExecution() + add("--workspace=$projectPath") + } + + private fun MutableList.addConfig(configFile: String) { + if (configFile.isNotEmpty()) { + add("--config=${configFile.toPathForExecution()}") + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/analysis/MagoJsonMessageHandler.kt b/src/main/kotlin/com/github/xepozz/mago/analysis/MagoJsonMessageHandler.kt new file mode 100644 index 0000000..bcc8571 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/analysis/MagoJsonMessageHandler.kt @@ -0,0 +1,118 @@ +package com.github.xepozz.mago.analysis + +import com.github.xepozz.mago.model.MagoAnnotationSpan +import com.github.xepozz.mago.model.MagoEdit +import com.github.xepozz.mago.model.MagoProblemDescription +import com.github.xepozz.mago.model.MagoReplacement +import com.github.xepozz.mago.model.MagoSeverity +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.intellij.openapi.util.io.FileUtil + +class MagoJsonMessageHandler { + fun parseJson(line: String, category: String = "analysis"): List { + return JsonParser.parseString(line) + .apply { if (this == null || this.isJsonNull) return emptyList() } + .asJsonObject + .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 = parseEdits(issue) + + val allAnnotations = issue.getAsJsonArray("annotations") + ?.map { it.asJsonObject } + ?: emptyList() + + val secondarySpans = allAnnotations + .filter { it.get("kind").asString == "Secondary" } + .mapNotNull { parseAnnotationSpan(it) } + + allAnnotations + .filter { ann -> + val kind = ann.get("kind").asString + kind == "Primary" || (code == "type-inspection" && kind == "Secondary") + } + .mapNotNull { annotation -> + val span = annotation.getAsJsonObject("span") ?: return@mapNotNull null + val filePath = extractFilePath(span) ?: 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( + severity = levelToSeverity(issue.get("level").asString), + lineNumber = span.getAsJsonObject("start")?.get("line")?.asInt + ?: return@mapNotNull null, + startChar = span.getAsJsonObject("start")?.get("offset")?.asInt + ?: return@mapNotNull null, + endChar = span.getAsJsonObject("end")?.get("offset")?.asInt + ?: return@mapNotNull null, + myMessage = message, + myFile = filePath, + code = code, + category = category, + help = help, + notes = notes, + edits = edits, + secondaryAnnotations = secondarySpans, + ) + } + } + ?: emptyList() + } + + private fun parseEdits(issue: JsonObject): List { + return 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 + ) + } + MagoEdit(fileId.get("name").asString, normalizePathFromMago(fileId.get("path").asString), replacements) + } ?: emptyList() + } + + private fun parseAnnotationSpan(annotation: JsonObject): MagoAnnotationSpan? { + val span = annotation.getAsJsonObject("span") ?: return null + val filePath = extractFilePath(span) ?: return null + return MagoAnnotationSpan( + message = annotation.get("message")?.asString ?: "", + kind = annotation.get("kind")?.asString ?: "Secondary", + filePath = filePath, + startOffset = span.getAsJsonObject("start")?.get("offset")?.asInt ?: return null, + endOffset = span.getAsJsonObject("end")?.get("offset")?.asInt ?: return null, + line = span.getAsJsonObject("start")?.get("line")?.asInt ?: return null, + ) + } + + private fun extractFilePath(span: JsonObject): String? { + return span.getAsJsonObject("file_id") + ?.get("path") + ?.asString + ?.removePrefix("\\\\?\\") + ?.let { FileUtil.toSystemIndependentName(normalizePathFromMago(it)) } + } + + /** Removes redundant /./ and normalize the path from mago output (e.g.: /opt/project/./src/foo.php). */ + private fun normalizePathFromMago(path: String): String = + path.replace("/./", "/") + + fun levelToSeverity(level: String?): MagoSeverity = when (level) { + "Error" -> MagoSeverity.ERROR + "Warning" -> MagoSeverity.WARNING + "Help", "Note" -> MagoSeverity.INFO + else -> MagoSeverity.INFO + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/analysis/MagoProblemsCollector.kt b/src/main/kotlin/com/github/xepozz/mago/analysis/MagoProblemsCollector.kt new file mode 100644 index 0000000..3f508cd --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/analysis/MagoProblemsCollector.kt @@ -0,0 +1,304 @@ +package com.github.xepozz.mago.analysis + +import com.github.xepozz.mago.MagoBundle +import com.github.xepozz.mago.configuration.MagoProjectConfiguration +import com.github.xepozz.mago.execution.LocalMagoRunner +import com.github.xepozz.mago.execution.MagoRunner +import com.github.xepozz.mago.execution.RemoteInterpreterMagoRunner +import com.github.xepozz.mago.model.MagoProblemDescription +import com.github.xepozz.mago.utils.DebugLogger +import com.intellij.execution.process.ProcessOutput +import com.intellij.openapi.project.Project +import java.io.File + +class MagoProblemsCollector( + private val parser: MagoJsonMessageHandler = MagoJsonMessageHandler(), +) { + data class Result( + val problems: List, + val errorOutput: String = "", + /** When non-null, problem paths refer to this temp file; the caller should rewrite to the real file name. */ + val tempFileNameUsed: String? = null + ) + + /** + * Collect problems for a file. When [stdinContent] is non-null, tries --stdin-input first so + * baselines use the real path; if Mago doesn't support it, falls back to a temp file and logs + * that the user should update Mago. [tempFileParentDir] is required when [stdinContent] is set + * (used for a fallback temp file). + */ + fun collectForFile( + project: Project, + filePath: String, + originalPath: String? = null, + stdinContent: String? = null, + tempFileParentDir: String? = null + ): Result { + val settings = MagoProjectConfiguration.getInstance(project) + if (!settings.enabled) return Result(emptyList(), "") + + val (runner, exe) = chooseRunnerAndExe(project, settings) + if (exe.isBlank()) return Result(emptyList(), MagoBundle.message("problemsCollector.exeNotConfigured")) + + val displayPath = originalPath ?: filePath + val resolved = MagoCliOptions.resolveForFile(project, settings, filePath) + + if (resolved.configFile.isEmpty()) { + if (settings.debug) { + DebugLogger.inform( + project, + title = MagoBundle.message("problemsCollector.skipped.title"), + content = MagoBundle.message("problemsCollector.skipped.content", displayPath) + ) + } + return Result(emptyList(), "") + } + + val workDir = File(resolved.workspaceDir.path) + return if (stdinContent != null && tempFileParentDir != null) { + tryStdinThenFallback( + project, + settings, + runner, + exe, + displayPath, + configDisplay = resolved.configFile, + stdinContent, + tempFileParentDir, + workDir + ) + } else { + runWithFile( + project, + settings, + runner, + exe, + filePath, + displayPath, + configDisplay = resolved.configFile, + workDir + ) + } + } + + private fun tryStdinThenFallback( + project: Project, + settings: MagoProjectConfiguration, + runner: MagoRunner, + exe: String, + displayPath: String, + configDisplay: String, + stdinContent: String, + tempFileParentDir: String, + workDir: File + ): Result { + val analyzeArgs = MagoCliOptions.getAnalyzeOptionsStdin(settings, project, displayPath) + val analyzeOut = runner.runWithStdin( + project, + exePath = exe, + analyzeArgs, + stdinContent, + timeoutMs = 30_000, + workDir + ) + if (isStdinUnsupported(analyzeOut)) { + if (settings.debug) { + DebugLogger.inform( + project, + title = MagoBundle.message("problemsCollector.stdinFallback.title"), + content = MagoBundle.message("problemsCollector.stdinFallback.content", displayPath) + ) + } + val tempFile = File.createTempFile( + ".mago-", + ".php", + File(tempFileParentDir) + ) + tempFile.deleteOnExit() + try { + tempFile.writeText(stdinContent, Charsets.UTF_8) + val runResult = runWithFile( + project, + settings, + runner, + exe, + tempFile.absolutePath, + displayPath, + configDisplay, + workDir + ) + return runResult.copy(tempFileNameUsed = tempFile.name) + } finally { + tempFile.delete() + } + } + val all = mutableListOf() + val errors = StringBuilder() + val debugLines = mutableListOf() + val analyze = parseOutput(analyzeOut, "analysis") + all += analyze.problems + if (analyze.errorOutput.isNotBlank()) errors.appendLine(analyze.errorOutput) + debugLines += "Analyze options (stdin): ${analyzeArgs.joinToString(" ")}" + if (settings.linterEnabled) { + val lintArgs = MagoCliOptions.getLintOptionsStdin(settings, project, displayPath) + val lintOut = runner.runWithStdin( + project, + exePath = exe, + lintArgs, + stdinContent, + timeoutMs = 30_000, + workDir = workDir + ) + val lint = parseOutput(lintOut, "lint") + all += lint.problems + if (lint.errorOutput.isNotBlank()) errors.appendLine(lint.errorOutput) + debugLines += "Lint options (stdin): ${lintArgs.joinToString(" ")}" + } + if (settings.guardEnabled) { + val guardArgs = MagoCliOptions.getGuardOptionsStdin(settings, project, displayPath) + val guardOut = runner.runWithStdin( + project, + exePath = exe, + guardArgs, + stdinContent, + timeoutMs = 30_000, + workDir + ) + val guard = parseOutput(guardOut, "guard") + all += guard.problems + if (guard.errorOutput.isNotBlank()) errors.appendLine(guard.errorOutput) + debugLines += "Guard options (stdin): ${guardArgs.joinToString(" ")}" + } + logDebugResult(project, all, displayPath, configDisplay, exe, debugLines) + return Result(problems = all, errorOutput = errors.toString(), tempFileNameUsed = null) + } + + private fun isStdinUnsupported(out: ProcessOutput): Boolean { + if (out.exitCode != 2) return false + val stderr = out.stderr + return stderr.contains("stdin-input") || stderr.contains("unexpected argument") + } + + private fun runWithFile( + project: Project, + settings: MagoProjectConfiguration, + runner: MagoRunner, + exe: String, + filePath: String, + displayPath: String, + configDisplay: String, + workDir: File + ): Result { + val all = mutableListOf() + val errors = StringBuilder() + val debugLines = mutableListOf() + val tempName = File(filePath).name + val originalName = File(displayPath).name + + val analyzeArgs = MagoCliOptions.getAnalyzeOptions(settings, project, filePath) + val analyze = runAndParse(project, runner, exe, analyzeArgs, "analysis", workDir) + all += analyze.problems + if (analyze.errorOutput.isNotBlank()) errors.appendLine(analyze.errorOutput) + debugLines += "Analyze options: ${ + analyzeArgs.joinToString(" ") { + it.replace( + oldValue = tempName, + newValue = originalName + ) + } + }" + + if (settings.linterEnabled) { + val lintArgs = MagoCliOptions.getLintOptions(settings, project, filePath) + val lint = runAndParse(project, runner, exe, lintArgs, "lint", workDir) + all += lint.problems + if (lint.errorOutput.isNotBlank()) errors.appendLine(lint.errorOutput) + debugLines += "Lint options: ${ + lintArgs.joinToString(" ") { + it.replace( + oldValue = tempName, + newValue = originalName + ) + } + }" + } + if (settings.guardEnabled) { + val guardArgs = MagoCliOptions.getGuardOptions(settings, project, filePath) + val guard = runAndParse(project, runner, exe, guardArgs, "guard", workDir) + all += guard.problems + if (guard.errorOutput.isNotBlank()) errors.appendLine(guard.errorOutput) + debugLines += "Guard options: ${ + guardArgs.joinToString(" ") { + it.replace( + oldValue = tempName, + newValue = originalName + ) + } + }" + } + + logDebugResult(project, all, displayPath, configDisplay, exe, debugLines) + return Result(problems = all, errorOutput = errors.toString(), tempFileNameUsed = null) + } + + private fun logDebugResult( + project: Project, + problems: List, + displayPath: String, + configDisplay: String, + exe: String, + debugLines: List + ) { + DebugLogger.inform( + project, + title = MagoBundle.message("problemsCollector.problems.title", problems.size), + content = "File: $displayPath
" + + "Config: $configDisplay
" + + "Executable: $exe

${debugLines.joinToString("

")}" + ) + } + + /** Parse runner output: JSON to problems, exit code 0/1 = success, >= 2 = failure. */ + private fun parseOutput(out: ProcessOutput, category: String): Result { + val stdout = out.stdout.trim() + val stderr = out.stderr.trim() + val problems = if (stdout.isEmpty()) emptyList() + else try { + parser.parseJson(stdout, category) + } catch (_: Exception) { + emptyList() + } + val errText = buildString { + if (out.exitCode >= 2) { + append("Exit code ${out.exitCode}. ") + if (stderr.isNotBlank()) append(stderr) + } + }.trim() + return Result(problems, errText) + } + + private fun chooseRunnerAndExe(project: Project, settings: MagoProjectConfiguration): Pair { + val interpreter = settings.resolveInterpreter(project) + return if (interpreter != null && interpreter.isRemote) { + RemoteInterpreterMagoRunner(interpreter) to settings.getEffectiveToolPath(project) + } else { + LocalMagoRunner() to settings.getEffectiveToolPath(project) + } + } + + private fun runAndParse( + project: Project, + runner: MagoRunner, + exePath: String, + args: List, + category: String, + workDir: File? = null + ): Result { + return try { + val out = runner.run(project, exePath, args, timeoutMs = 30_000, workDir) + parseOutput(out, category) + } catch (t: Throwable) { + Result(emptyList(), "${t::class.java.simpleName}: ${t.message ?: MagoBundle.message("problemsCollector.error.unknown")}") + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/annotator/MagoExternalAnnotator.kt b/src/main/kotlin/com/github/xepozz/mago/annotator/MagoExternalAnnotator.kt new file mode 100644 index 0000000..88e0605 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/annotator/MagoExternalAnnotator.kt @@ -0,0 +1,443 @@ +package com.github.xepozz.mago.annotator + +import com.github.xepozz.mago.MagoBundle +import com.github.xepozz.mago.analysis.MagoProblemsCollector +import com.github.xepozz.mago.annotator.MagoExternalAnnotator.AnnotationResult +import com.github.xepozz.mago.annotator.MagoExternalAnnotator.CollectedInfo +import com.github.xepozz.mago.intentions.suppress.createSuppressSubmenuAction +import com.github.xepozz.mago.intentions.apply.ApplyAllScope +import com.github.xepozz.mago.intentions.apply.MagoApplyEditAction +import com.github.xepozz.mago.intentions.apply.MagoApplyEditSubmenuAction +import com.github.xepozz.mago.intentions.apply.filterEditsByExactSafety +import com.github.xepozz.mago.intentions.apply.maxSafetyLevel +import com.github.xepozz.mago.intentions.MagoNavigateToRelatedAction +import com.github.xepozz.mago.intentions.MagoRemoveRedundantFileAction +import com.github.xepozz.mago.model.MagoEdit +import com.github.xepozz.mago.model.MagoProblemDescription +import com.github.xepozz.mago.model.MagoSeverity +import com.github.xepozz.mago.utils.NotificationsUtil +import com.intellij.lang.annotation.AnnotationHolder +import com.intellij.lang.annotation.ExternalAnnotator +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.util.text.StringUtil +import com.intellij.psi.PsiErrorElement +import com.intellij.psi.PsiFile +import com.intellij.psi.util.PsiTreeUtil +import com.jetbrains.php.lang.psi.elements.Function + +class MagoExternalAnnotator : + ExternalAnnotator() { + + data class CollectedInfo(val file: PsiFile, val editorText: String) + data class AnnotationResult( + val problems: List, + val errorOutput: String, + val analyzedContent: String + ) + + override fun collectInformation(file: PsiFile, editor: Editor, hasErrors: Boolean): CollectedInfo? { + // hasErrors is true for semantic errors too (e.g.: abstract instantiation). + // Only skip when the PSI tree has actual syntax/parse errors. + if (PsiTreeUtil.findChildOfType(file, PsiErrorElement::class.java) != null) return null + return collectInformation(file) + } + + override fun collectInformation(file: PsiFile): CollectedInfo? { + if (file.language.id != "PHP") return null + return CollectedInfo(file, file.text) + } + + override fun doAnnotate(collectedInfo: CollectedInfo): AnnotationResult { + val empty = AnnotationResult(emptyList(), "", "") + val virtualFile = collectedInfo.file.virtualFile ?: return empty + val filePath = virtualFile.path + val editorText = collectedInfo.editorText + val parentDir = virtualFile.parent?.path + + return try { + val collector = MagoProblemsCollector() + val result = if (parentDir != null) { + collector.collectForFile( + collectedInfo.file.project, + filePath, + filePath, + stdinContent = editorText, + tempFileParentDir = parentDir + ) + } else { + val tempFile = java.io.File.createTempFile(".mago-", ".php") + tempFile.deleteOnExit() + try { + tempFile.writeText(editorText, Charsets.UTF_8) + val r = collector.collectForFile( + collectedInfo.file.project, + filePath = tempFile.absolutePath, + originalPath = filePath + ) + r.copy(tempFileNameUsed = tempFile.name) + } finally { + tempFile.delete() + } + } + + val problems = result.tempFileNameUsed?.let { tempName -> + val originalName = virtualFile.name + result.problems.map { p -> + p.copy( + myFile = p.myFile.replace(tempName, originalName), + edits = p.edits.map { e -> + e.copy( + name = e.name.replace(tempName, originalName), + path = e.path.replace(tempName, originalName) + ) + }, + secondaryAnnotations = p.secondaryAnnotations.map { s -> + s.copy(filePath = s.filePath.replace(tempName, originalName)) + } + ) + } + } ?: result.problems + + val allProblems = problems.distinctBy { "${it.startChar}-${it.endChar}-${it.code}-${it.myMessage}" } + AnnotationResult(allProblems, result.errorOutput, editorText) + } catch (_: Exception) { + empty + } + } + + override fun apply(file: PsiFile, annotationResult: AnnotationResult, holder: AnnotationHolder) { + if (annotationResult.analyzedContent.isEmpty()) return + if (annotationResult.analyzedContent != file.text) return + + val problems = annotationResult.problems + + if (problems.isEmpty()) { + if (annotationResult.errorOutput.isNotBlank()) { + NotificationsUtil.error( + file.project, + title = MagoBundle.message("annotator.failed.title"), + content = annotationResult.errorOutput + ) + } + return + } + + val fileText = file.text + val fileLength = fileText.length + val currentFilePath = file.virtualFile?.path ?: return + + val groupedProblems = problems.groupBy { problem -> + ReadAction.compute { + resolveHighlightRange(problem, file, fileText, fileLength) + } + } + + val allFileEdits = problems.flatMap { it.edits } + val applyAllByLevel = buildApplyAllActions(allFileEdits) + val applyAllCountByLevel = buildApplyAllCounts(allFileEdits) + + for ((textRange, rangeProblems) in groupedProblems) { + val highestSeverityProblem = rangeProblems.maxByOrNull { it.severity.ordinal } ?: continue + + val severity = when (highestSeverityProblem.severity) { + MagoSeverity.ERROR -> HighlightSeverity.ERROR + MagoSeverity.WARNING -> HighlightSeverity.WARNING + MagoSeverity.INFO -> HighlightSeverity.WEAK_WARNING + } + + val gutterMessage = if (rangeProblems.size == 1) { + val problem = rangeProblems.first() + MagoBundle.message("annotator.gutterMessage", problem.myMessage, problem.category, problem.code) + } else { + MagoBundle.message("annotator.multipleIssues") + } + + val builder = holder.newAnnotation(severity, gutterMessage) + .range(textRange) + .tooltip(formatGroupedHtmlMessage(rangeProblems)) + + attachEditFixes(builder, rangeProblems, applyAllByLevel, applyAllCountByLevel) + attachRemoveRedundantFileFix(builder, rangeProblems) + attachSuppressFixes(builder, rangeProblems, fileText) + attachRelatedNavigation(builder, rangeProblems, file, fileText, fileLength, currentFilePath) + + builder.create() + } + } + + /** + * Determines the highlight range for a problem. + * Per-rule overrides go here to keep the logic clean and extensible. + */ + private fun resolveHighlightRange( + problem: MagoProblemDescription, + file: PsiFile, + fileText: String, + fileLength: Int, + ): TextRange { + val rawRange = byteRangeToCharTextRange(fileText, problem.startChar, problem.endChar) + + val range = when (problem.code) { + "missing-return-type" -> resolveFunctionNameRange(file, rawRange, fileLength) + "unused-pragma" -> resolveFromSecondary(problem, fileText, messagePrefix = "...for this code") ?: rawRange + else -> rawRange + } + + return finalizeRange(range, file, fileText, fileLength) + } + + private fun resolveFunctionNameRange(file: PsiFile, rawRange: TextRange, fileLength: Int): TextRange { + val start = rawRange.startOffset.coerceIn(0, fileLength) + val element = file.findElementAt(start) + val function = PsiTreeUtil.getParentOfType(element, Function::class.java) ?: return rawRange + val nameIdentifier = function.nameIdentifier ?: return rawRange + return nameIdentifier.textRange + } + + private fun resolveFromSecondary( + problem: MagoProblemDescription, + fileText: String, + @Suppress("SameParameterValue") messagePrefix: String + ): TextRange? { + val secondary = problem.secondaryAnnotations.firstOrNull { it.message.startsWith(messagePrefix) } + ?: return null + return byteRangeToCharTextRange(fileText, secondary.startOffset, secondary.endOffset) + } + + private fun finalizeRange(range: TextRange, file: PsiFile, fileText: String, fileLength: Int): TextRange { + var start = range.startOffset.coerceIn(0, fileLength) + var end = range.endOffset.coerceIn(start, fileLength) + + // Don't skip leading whitespace or expand when the range is tiny (e.g.: trailing space at 453-454). + // Otherwise, we'd move the highlight off the error onto the next token. + if (end - start <= 2) { + return TextRange.create(start, end) + } + + while (start < end && fileText[start].isWhitespace()) { + start++ + } + + val elementAtStart = file.findElementAt(start) + if (elementAtStart != null) { + val elEnd = elementAtStart.textRange.endOffset.coerceIn(0, fileLength) + if (elEnd > end && elementAtStart.textRange.startOffset <= start) { + end = elEnd + } + } + + return TextRange.create(start, end) + } + + private fun isSameFile(secondaryPath: String, currentPath: String): Boolean { + if (secondaryPath == currentPath) return true + val secondaryName = secondaryPath.substringAfterLast('/') + val currentName = currentPath.substringAfterLast('/') + return secondaryName == currentName && secondaryPath.endsWith(currentName) + } + + /** + * Adds "Navigate to cause" intention actions for each secondary annotation. + * Same-file secondaries jump to the exact offset; cross-file secondaries open the file and jump to the line. + * Skips problems whose rule is in [noNavigateToCauseRuleIds]. + */ + private fun attachRelatedNavigation( + builder: com.intellij.lang.annotation.AnnotationBuilder, + rangeProblems: List, + file: PsiFile, + fileText: String, + fileLength: Int, + currentFilePath: String, + ) { + val addedNavs = mutableSetOf() + for (problem in rangeProblems) { + if ("${problem.category}:${problem.code}" in noNavigateToCauseRuleIds) continue + for (secondary in problem.secondaryAnnotations) { + val sameFile = isSameFile(secondary.filePath, currentFilePath) + val (offset, targetPath) = if (sameFile) { + val charRange = byteRangeToCharTextRange( + fileText, + byteStart = secondary.startOffset, + byteEnd = secondary.endOffset + ) + val finalRange = finalizeRange(charRange, file, fileText, fileLength) + finalRange.startOffset to null as String? + } else { + 0 to secondary.filePath + } + val key = "${targetPath ?: currentFilePath}:$offset-${secondary.message}" + if (!addedNavs.add(key)) continue + + builder.withFix( + MagoNavigateToRelatedAction( + secondary.message, + targetOffset = offset, + secondary.line, + targetFilePath = targetPath + ) + ) + } + } + } + + private fun buildApplyAllActions(allEdits: List): List { + return listOf(ApplyAllScope.SAFE_ONLY, ApplyAllScope.POTENTIALLY_UNSAFE, ApplyAllScope.UNSAFE).map { scope -> + val filtered = filterEditsByExactSafety(allEdits, scope.maxSafetyLevel) + if (filtered.isEmpty()) null else MagoApplyEditAction( + edits = filtered, + isApplyAll = true, + applyAllScope = scope + ) + } + } + + private fun buildApplyAllCounts(allEdits: List): List { + return listOf(ApplyAllScope.SAFE_ONLY, ApplyAllScope.POTENTIALLY_UNSAFE, ApplyAllScope.UNSAFE).map { scope -> + filterEditsByExactSafety(allEdits, scope.maxSafetyLevel).sumOf { it.replacements.size } + } + } + + private fun attachEditFixes( + builder: com.intellij.lang.annotation.AnnotationBuilder, + rangeProblems: List, + applyAllByLevel: List, + applyAllCountByLevel: List + ) { + 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 { e -> e to p } } + .distinctBy { (e, _) -> e } + .map { (e, p) -> + val description = p.help.ifBlank { p.myMessage } + MagoApplyEditAction(listOf(e), 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 { + builder.withFix(MagoApplyEditSubmenuAction(actions.first(), actions)) + } + } + } + } + + /** Rule ids that must not get a "Suppress" intention (e.g.: use a different fix or none). */ + private val nonIgnorableRuleIds = setOf( + "analysis:semantics", // Does not work, it's a semantic error + "lint:strict-types", // Adds it above the `, + ) { + if (rangeProblems.any { it.category == "lint" && it.code == "no-redundant-file" }) { + builder.withFix(MagoRemoveRedundantFileAction()) + } + } + + private fun attachSuppressFixes( + builder: com.intellij.lang.annotation.AnnotationBuilder, + rangeProblems: List, + fileText: String + ) { + val addedFixes = mutableSetOf() + for (problem in rangeProblems) { + if ("${problem.category}:${problem.code}" in nonIgnorableRuleIds) continue + + val problemCharRange = byteRangeToCharTextRange( + fileText, + byteStart = problem.startChar, + byteEnd = problem.endChar + ) + var problemStartOffset = problemCharRange.startOffset + while (problemStartOffset < problemCharRange.endOffset && fileText[problemStartOffset].isWhitespace()) { + problemStartOffset++ + } + + val key = "suppress_${problem.category}_${problem.code}" + if (addedFixes.add(key)) { + builder.withFix( + createSuppressSubmenuAction( + problem.category, + problem.code, + problem.lineNumber, + problemStartOffset + ) + ) + } + } + } + + private fun formatGroupedHtmlMessage(problems: List): String { + val sb = StringBuilder("") + + problems.forEachIndexed { index, problem -> + if (index > 0) sb.append("

") + + sb.append("").append(MagoBundle.message("annotator.tooltip.magoLabel")).append(" ").append(escapeAndFormat(problem.myMessage)) + .append(" [").append(problem.category).append(":").append(problem.code).append("]") + + for (note in problem.notes) { + sb.append("

").append(escapeAndFormat(note)) + } + + if (problem.help.isNotEmpty()) { + sb.append("

").append(MagoBundle.message("annotator.tooltip.helpLabel")).append(" ").append(escapeAndFormat(problem.help)) + } + + if (problem.secondaryAnnotations.isNotEmpty()) { + sb.append("

").append(MagoBundle.message("annotator.tooltip.relatedLabel")).append("") + for (secondary in problem.secondaryAnnotations) { + sb.append("
  ↳ ").append(escapeAndFormat(secondary.message)).append("") + sb.append(" ").append(MagoBundle.message("annotator.tooltip.lineLabel", secondary.line + 1)).append("") + } + } + } + + 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 byteRangeToCharTextRange(text: String, byteStart: Int, byteEnd: Int): TextRange { + val bytes = text.toByteArray(Charsets.UTF_8) + val safeByteStart = byteStart.coerceIn(0, bytes.size) + val safeByteEnd = byteEnd.coerceIn(safeByteStart, bytes.size) + + val charStart = String(bytes.copyOf(safeByteStart), Charsets.UTF_8).length + val charEnd = String(bytes.copyOf(safeByteEnd), Charsets.UTF_8).length + return TextRange.create(charStart, charEnd) + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/composer/MagoComposerAutoDetectActivity.kt b/src/main/kotlin/com/github/xepozz/mago/composer/MagoComposerAutoDetectActivity.kt new file mode 100644 index 0000000..8f26eda --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/composer/MagoComposerAutoDetectActivity.kt @@ -0,0 +1,85 @@ +package com.github.xepozz.mago.composer + +import com.github.xepozz.mago.MagoBundle +import com.github.xepozz.mago.configuration.MagoConfigurationManager +import com.github.xepozz.mago.configuration.MagoProjectConfiguration +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.vfs.newvfs.BulkFileListener +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import java.nio.file.Files +import java.nio.file.Path + +class MagoComposerAutoDetectActivity : ProjectActivity { + companion object { + private const val PACKAGE: String = "carthage-software/mago" + + private const val RELATIVE_PATH: String = "bin/mago" + private const val RELATIVE_PATH_WINDOWS: String = "bin/mago.bat" + } + + override suspend fun execute(project: Project) { + val basePath = project.basePath ?: return + + maybeSuggestOrApply(project, basePath) + + project.messageBus.connect().subscribe( + topic = VirtualFileManager.VFS_CHANGES, + handler = object : BulkFileListener { + override fun after(events: List) { + val needle = "/vendor/$RELATIVE_PATH".replace('\\', '/') + val touched = events.any { event -> + val path = event.path.replace('\\', '/') + path.endsWith(needle) + } + if (!touched) return + + maybeSuggestOrApply(project, basePath) + } + } + ) + } + + private fun maybeSuggestOrApply(project: Project, basePath: String) { + val settings = MagoProjectConfiguration.getInstance(project) + + if (settings.isRemoteInterpreter(project)) return + + val localConfig = MagoConfigurationManager.getInstance(project).getOrCreateLocalSettings() + if (localConfig.toolPath.isNotBlank()) return + + val candidate = composerMagoPath(basePath) ?: return + + val group = NotificationGroupManager.getInstance().getNotificationGroup("Mago") + @Suppress("DialogTitleCapitalization") + group.createNotification( + title = MagoBundle.message("composer.detected.title"), + content = MagoBundle.message("composer.detected.content", PACKAGE, candidate), + type = NotificationType.INFORMATION + ) + .addAction( + NotificationAction.createSimple(MagoBundle.message("composer.action.useMago")) { + localConfig.toolPath = candidate.toString() + } + ) + .addAction( + NotificationAction.createSimple(MagoBundle.message("composer.action.openSettings")) { + ShowSettingsUtil.getInstance() + .showSettingsDialog(project, MagoBundle.message("mago.title")) + } + ) + .notify(project) + } + + private fun composerMagoPath(basePath: String): Path? { + val rel = if (SystemInfo.isWindows) RELATIVE_PATH_WINDOWS else RELATIVE_PATH + val path = Path.of(basePath, "vendor", rel) + return if (Files.isRegularFile(path)) path else null + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/composer/MagoComposerConfig.kt b/src/main/kotlin/com/github/xepozz/mago/composer/MagoComposerConfig.kt deleted file mode 100644 index f000982..0000000 --- a/src/main/kotlin/com/github/xepozz/mago/composer/MagoComposerConfig.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.github.xepozz.mago.composer - -import com.github.xepozz.mago.configuration.MagoConfiguration -import com.github.xepozz.mago.configuration.MagoConfigurationManager -import com.github.xepozz.mago.qualityTool.MagoQualityToolType -import com.github.xepozz.mago.qualityTool.MagoValidationInspection -import com.intellij.openapi.project.Project -import com.intellij.openapi.util.SystemInfo -import com.jetbrains.php.tools.quality.QualityToolsComposerConfig - -class MagoComposerConfig : QualityToolsComposerConfig( - PACKAGE, - RELATIVE_PATH -) { - override fun getQualityInspectionShortName() = MagoQualityToolType.INSTANCE.inspectionId - - override fun getConfigurationManager(project: Project) = MagoConfigurationManager.getInstance(project) - - override fun getSettings() = MagoOpenSettingsProvider.INSTANCE - - companion object { - private const val PACKAGE: String = "carthage-software/mago" - private val RELATIVE_PATH: String = "bin/mago${if (SystemInfo.isWindows) ".bat" else ""}" - } -} diff --git a/src/main/kotlin/com/github/xepozz/mago/composer/MagoOpenSettingsProvider.kt b/src/main/kotlin/com/github/xepozz/mago/composer/MagoOpenSettingsProvider.kt index 167a280..a673f99 100644 --- a/src/main/kotlin/com/github/xepozz/mago/composer/MagoOpenSettingsProvider.kt +++ b/src/main/kotlin/com/github/xepozz/mago/composer/MagoOpenSettingsProvider.kt @@ -8,10 +8,6 @@ import com.jetbrains.php.composer.actions.log.ComposerLogMessageBuilder class MagoOpenSettingsProvider : ComposerLogMessageBuilder.Settings("\u300C") { override fun show(project: Project) { ShowSettingsUtil.getInstance() - .showSettingsDialog(project, MagoBundle.message("configurable.quality.tool.php.mago")) + .showSettingsDialog(project, MagoBundle.message("mago.title")) } - - companion object { - val INSTANCE = MagoOpenSettingsProvider() - } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/xepozz/mago/config/MagoConfigSchemaUtil.kt b/src/main/kotlin/com/github/xepozz/mago/config/MagoConfigSchemaUtil.kt new file mode 100644 index 0000000..1cf57de --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/config/MagoConfigSchemaUtil.kt @@ -0,0 +1,115 @@ +package com.github.xepozz.mago.config + +import com.github.xepozz.mago.config.reference.ConfigStructure +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.project.Project + +object MagoConfigSchemaUtil { + private const val REF = $$"$ref" + private const val DEFS = $$"$defs" + private const val DEFS_PREFIX = $$"#/$defs/" + + /** + * Full schema root (with "properties", "$defs", etc.) from `mago config --schema`, or null if not available. + */ + fun getSchemaRoot(project: Project): JsonObject? { + val schemaFile = project.getService(MagoSchemaHolder::class.java).getSchema() ?: return null + val json = ReadAction.compute { + schemaFile.inputStream.use { it.readBytes().decodeToString() } + } ?: return null + return try { + JsonParser.parseString(json).asJsonObject + } catch (_: Exception) { + null + } + } + + /** + * Returns a copy of the schema with all `#/$defs/` refs inlined, so path navigation needs no ref handling. + * Stops at circular refs (reuses the same def object). + */ + fun dereferenceSchema(root: JsonObject): JsonObject { + root.getAsJsonObject(DEFS) ?: return root.deepCopy() + return dereferenceElement(root, root.deepCopy(), mutableSetOf()) as JsonObject + } + + private fun dereferenceElement(root: JsonObject, el: JsonElement, resolving: MutableSet): JsonElement { + return when { + el.isJsonObject -> dereferenceObject(root, el.asJsonObject, resolving) + el.isJsonArray -> dereferenceArray(root, el.asJsonArray, resolving) + else -> el + } + } + + private fun dereferenceObject(root: JsonObject, obj: JsonObject, resolving: MutableSet): JsonObject { + val ref = obj.get(REF)?.takeIf { it.isJsonPrimitive }?.asString + if (ref != null && ref.startsWith(DEFS_PREFIX)) { + val name = ref.removePrefix(DEFS_PREFIX) + if (name in resolving) return obj + val def = root.getAsJsonObject(DEFS)?.getAsJsonObject(name) ?: return obj + resolving.add(name) + val out = dereferenceObject(root, def.deepCopy(), resolving) + resolving.remove(name) + return out + } + val result = JsonObject() + for ((key, value) in obj.entrySet()) { + result.add(key, dereferenceElement(root, value, resolving)) + } + return result + } + + private fun dereferenceArray(root: JsonObject, arr: JsonArray, resolving: MutableSet): JsonArray { + val result = JsonArray() + for (el in arr) { + result.add(dereferenceElement(root, el, resolving)) + } + return result + } + + private fun deepCopy(el: JsonElement): JsonElement = + when { + el.isJsonObject -> el.asJsonObject.deepCopy() + el.isJsonArray -> el.asJsonArray.let { a -> JsonArray().apply { a.forEach { add(deepCopy(it)) } } } + else -> el + } + + /** + * Navigates a dot-separated path (e.g. "guard.perimeter.rules") in a dereferenced schema. + * For array-typed nodes (e.g. "rules"), returns the item schema so "required" / "description" applied to each entry. + */ + fun getObjectAtPath(dereferencedRoot: JsonObject, path: String): JsonObject? { + var current: JsonObject? = dereferencedRoot.getAsJsonObject("properties") ?: return null + val parts = path.split(".") + for (i in parts.indices) { + current = current?.getAsJsonObject(parts[i]) ?: return null + if (i < parts.lastIndex) { + current = current.getAsJsonObject("properties") ?: return null + } + } + var result = current ?: return null + if (result.get("type")?.asString == "array") { + result = result.getAsJsonObject("items") ?: return null + } + return result + } + + /** + * Returns section name -> description from the config schema. + */ + fun getSectionDescriptions(project: Project): Map { + val root = getSchemaRoot(project) ?: return emptyMap() + val resolved = dereferenceSchema(root) + val result = mutableMapOf() + for (sectionName in ConfigStructure.STRUCTURE.keys) { + val obj = getObjectAtPath(resolved, sectionName) ?: continue + val desc = obj.get("description") ?: continue + if (desc.isJsonPrimitive) result[sectionName] = desc.asString + } + return result + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/config/MagoSchemaHolder.kt b/src/main/kotlin/com/github/xepozz/mago/config/MagoSchemaHolder.kt index 5557f15..5496178 100644 --- a/src/main/kotlin/com/github/xepozz/mago/config/MagoSchemaHolder.kt +++ b/src/main/kotlin/com/github/xepozz/mago/config/MagoSchemaHolder.kt @@ -1,6 +1,9 @@ package com.github.xepozz.mago.config import com.github.xepozz.mago.configuration.MagoProjectConfiguration +import com.github.xepozz.mago.execution.LocalMagoRunner +import com.github.xepozz.mago.execution.MagoRunner +import com.github.xepozz.mago.execution.RemoteInterpreterMagoRunner import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service import com.intellij.openapi.project.Project @@ -8,7 +11,6 @@ import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.vfs.VirtualFile import com.intellij.testFramework.LightVirtualFile import com.jetbrains.jsonSchema.ide.JsonSchemaService -import com.jetbrains.php.tools.quality.QualityToolProcessCreator @Service(Service.Level.PROJECT) class MagoSchemaHolder(val project: Project) { @@ -18,35 +20,38 @@ class MagoSchemaHolder(val project: Project) { fun getSchema() = mySchemaFile fun dumpSchema() { - // don't run dump several times if (run) return run = true - val magoConfiguration = MagoProjectConfiguration.getInstance(project) - .run { findConfigurationById(selectedConfigurationId, project) } - ?: return + val settings = MagoProjectConfiguration.getInstance(project) + + val (runner, magoExecutable) = chooseRunnerAndExe(settings) + if (magoExecutable.isBlank()) return ApplicationManager.getApplication().executeOnPooledThread { - val magoExecutable = magoConfiguration.toolPath - - QualityToolProcessCreator - .getToolOutput( - project, - null, - magoExecutable, - 1, - "Dumping schema...", - null, - "config", - "--schema", - ) - .apply { - if (exitCode != 0) { - error("Failed to dump mago schema: ${stderr}") - } - mySchemaFile = LightVirtualFile("mago.schema.json", stdout) - restartSchemaServices() - } + val out = runner.run( + project = project, + exePath = magoExecutable, + args = listOf("config", "--schema"), + timeoutMs = 30_000 + ) + + if (out.exitCode != 0) { + run = false + return@executeOnPooledThread + } + + mySchemaFile = LightVirtualFile("mago.schema.json", out.stdout) + restartSchemaServices() + } + } + + private fun chooseRunnerAndExe(settings: MagoProjectConfiguration): Pair { + val interpreter = settings.resolveInterpreter(project) + return if (interpreter != null && interpreter.isRemote) { + RemoteInterpreterMagoRunner(interpreter) to settings.getEffectiveToolPath(project) + } else { + LocalMagoRunner() to settings.getEffectiveToolPath(project) } } diff --git a/src/main/kotlin/com/github/xepozz/mago/config/MagoTomlJsonSchemaProviderFactory.kt b/src/main/kotlin/com/github/xepozz/mago/config/MagoTomlJsonSchemaProviderFactory.kt index 46399c3..5736952 100644 --- a/src/main/kotlin/com/github/xepozz/mago/config/MagoTomlJsonSchemaProviderFactory.kt +++ b/src/main/kotlin/com/github/xepozz/mago/config/MagoTomlJsonSchemaProviderFactory.kt @@ -1,17 +1,20 @@ package com.github.xepozz.mago.config +import com.github.xepozz.mago.MagoBundle import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import com.jetbrains.jsonSchema.extension.JsonSchemaFileProvider import com.jetbrains.jsonSchema.extension.JsonSchemaProviderFactory import com.jetbrains.jsonSchema.extension.SchemaType +// If you are wondering why there are no inspections/validation: +// https://youtrack.jetbrains.com/projects/IJPL/issues/IJPL-104165/JSON-schema-code-insight-for-TOML class MagoTomlJsonSchemaProviderFactory : JsonSchemaProviderFactory { override fun getProviders(project: Project) = listOf( object : JsonSchemaFileProvider { override fun isAvailable(file: VirtualFile) = file.name == "mago.toml" - override fun getName() = "Mago Toml" + override fun getName() = MagoBundle.message("schema.magoToml.name") override fun getSchemaType() = SchemaType.embeddedSchema diff --git a/src/main/kotlin/com/github/xepozz/mago/config/reference/ConfigCompletionContributor.kt b/src/main/kotlin/com/github/xepozz/mago/config/reference/ConfigCompletionContributor.kt index e0a662f..dc25c51 100644 --- a/src/main/kotlin/com/github/xepozz/mago/config/reference/ConfigCompletionContributor.kt +++ b/src/main/kotlin/com/github/xepozz/mago/config/reference/ConfigCompletionContributor.kt @@ -1,23 +1,30 @@ package com.github.xepozz.mago.config.reference import com.github.xepozz.mago.MagoIcons +import com.github.xepozz.mago.config.MagoConfigSchemaUtil import com.intellij.codeInsight.completion.CompletionContributor import com.intellij.codeInsight.completion.CompletionParameters import com.intellij.codeInsight.completion.CompletionProvider import com.intellij.codeInsight.completion.CompletionResultSet import com.intellij.codeInsight.completion.CompletionType +import com.intellij.codeInsight.completion.InsertHandler +import com.intellij.codeInsight.completion.InsertionContext +import com.intellij.codeInsight.lookup.LookupElement import com.intellij.codeInsight.lookup.LookupElementBuilder import com.intellij.patterns.PlatformPatterns import com.intellij.psi.util.findParentOfType import com.intellij.util.ProcessingContext +import org.toml.lang.psi.TomlFile import org.toml.lang.psi.TomlHeaderOwner import org.toml.lang.psi.TomlKey import org.toml.lang.psi.TomlKeySegment import org.toml.lang.psi.TomlKeyValue +import org.toml.lang.psi.TomlTable import org.toml.lang.psi.TomlTableHeader class ConfigCompletionContributor : CompletionContributor() { init { + // Inside [ ] or [[ ]] — complete section name only (brackets already present) extend( CompletionType.BASIC, PlatformPatterns @@ -32,14 +39,12 @@ class ConfigCompletionContributor : CompletionContributor() { context: ProcessingContext, results: CompletionResultSet ) { - val parent = parameters.position.parent.parent as? TomlKey ?: return - - val prefix = parent.text.substring(0, parameters.offset - parent.textRange.startOffset) - results.withPrefixMatcher(prefix) + val header = parameters.position.findParentOfType() ?: return + val doubleBracketMode = header.text.startsWith("[[") + val ctx = getSectionCompletionContext(parameters, doubleBracketMode) ?: return + results.withPrefixMatcher(ctx.prefix) .apply { - ConfigStructure - .STRUCTURE - .keys + ctx.sectionNames .map { LookupElementBuilder.create(it) .withIcon(MagoIcons.MAGO) @@ -50,6 +55,46 @@ class ConfigCompletionContributor : CompletionContributor() { } } ) + // At the top level (key not inside any [section]) — complete section name and insert as [section] or [[section]] + extend( + CompletionType.BASIC, + PlatformPatterns + .psiElement() + .withParent(TomlKeySegment::class.java) + .withSuperParent(2, PlatformPatterns.psiElement(TomlKey::class.java)) + .withSuperParent(3, PlatformPatterns.psiElement(TomlKeyValue::class.java)), + object : CompletionProvider() { + override fun addCompletions( + parameters: CompletionParameters, + context: ProcessingContext, + results: CompletionResultSet + ) { + val ctx = getSectionCompletionContext(parameters, null) ?: return + val project = parameters.editor.project ?: parameters.position.project + val descriptions = MagoConfigSchemaUtil.getSectionDescriptions(project) + results.withPrefixMatcher(ctx.prefix) + .apply { + ctx.sectionNames + .map { sectionName -> + val desc = descriptions[sectionName]?.let { truncateForTail(it) } + LookupElementBuilder.create(sectionName) + .withIcon(MagoIcons.MAGO) + .bold() + .withInsertHandler(SectionHeaderInsertHandler(sectionName)) + .let { if (desc != null) it.withTailText(" $desc", true) else it } + } + .apply { addAllElements(this) } + } + val allSectionNames = ConfigStructure.STRUCTURE.keys + val existingTopLevelKeys = getExistingTopLevelKeys(parameters) + results.runRemainingContributors(parameters) { result -> + val name = result.lookupElement.lookupString + if (name in allSectionNames || name in existingTopLevelKeys) return@runRemainingContributors + results.addElement(result.lookupElement) + } + } + } + ) extend( CompletionType.BASIC, PlatformPatterns @@ -57,9 +102,10 @@ class ConfigCompletionContributor : CompletionContributor() { .withParent(TomlKeySegment::class.java) .withSuperParent(2, PlatformPatterns.psiElement(TomlKey::class.java)) .withSuperParent(3, PlatformPatterns.psiElement(TomlKeyValue::class.java)) - .withSuperParent(4, + .withSuperParent( + 4, PlatformPatterns.psiElement(TomlHeaderOwner::class.java) - ), + ), object : CompletionProvider() { override fun addCompletions( parameters: CompletionParameters, @@ -69,17 +115,86 @@ class ConfigCompletionContributor : CompletionContributor() { val element = parameters.position.parent as? TomlKeySegment ?: return val table = element.findParentOfType() ?: return val key = table.header.key?.text ?: return + val existingKeys = getExistingKeysInTable(table) + val suggestedKeys = ConfigStructure.STRUCTURE[key]?.filter { it !in existingKeys }.orEmpty() - ConfigStructure - .STRUCTURE[key] - ?.map { + suggestedKeys + .map { LookupElementBuilder.create(it) .withIcon(MagoIcons.MAGO) .bold() } - ?.apply { results.addAllElements(this) } + .apply { results.addAllElements(this) } } } ) } -} \ No newline at end of file +} + +private data class SectionCompletionContext( + val parent: TomlKey, + val prefix: String, + val sectionNames: List, +) + +private fun getSectionCompletionContext( + parameters: CompletionParameters, + doubleBracketMode: Boolean?, +): SectionCompletionContext? { + val parent = parameters.position.parent.parent as? TomlKey ?: return null + val prefix = parent.text.substring(0, parameters.offset - parent.textRange.startOffset) + val existingSections = getExistingSectionNames(parameters) + val sectionNames = when (doubleBracketMode) { + true -> ConfigStructure.STRUCTURE.keys + .filter { it in ConfigStructure.SECTIONS_WITH_DOUBLE_BRACKETS && it !in existingSections } + + false -> ConfigStructure.STRUCTURE.keys + .filter { it !in ConfigStructure.SECTIONS_WITH_DOUBLE_BRACKETS && it !in existingSections } + + null -> ConfigStructure.STRUCTURE.keys.filter { it !in existingSections } + } + return SectionCompletionContext(parent, prefix, sectionNames) +} + +private fun getExistingSectionNames(parameters: CompletionParameters): Set { + val file = parameters.position.containingFile as? TomlFile ?: return emptySet() + return file.children + .filterIsInstance() + .mapNotNull { table -> + table.header.text?.let { t -> + when { + t.startsWith("[[") && t.endsWith("]]") -> t.removeSurrounding("[[", "]]") + else -> t.removeSurrounding("[", "]") + } + } + } + .toSet() +} + +private fun getExistingTopLevelKeys(parameters: CompletionParameters): Set { + val file = parameters.position.containingFile as? TomlFile ?: return emptySet() + return file.children + .filterIsInstance() + .mapNotNull { it.key.text } + .toSet() +} + +private fun getExistingKeysInTable(table: TomlHeaderOwner): Set { + val tomlTable = table as? TomlTable ?: return emptySet() + return tomlTable.entries + .mapNotNull { it.key.text } + .toSet() +} + +private fun truncateForTail(description: String, maxLen: Int = 80): String { + val trimmed = description.trim() + return if (trimmed.length <= maxLen) trimmed else trimmed.take(maxLen).trimEnd() + "..." +} + +private class SectionHeaderInsertHandler(private val sectionName: String) : InsertHandler { + override fun handleInsert(context: InsertionContext, item: LookupElement) { + val brackets = + if (sectionName in ConfigStructure.SECTIONS_WITH_DOUBLE_BRACKETS) "[[$sectionName]]" else "[$sectionName]" + context.document.replaceString(context.startOffset, context.tailOffset, brackets) + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/config/reference/ConfigStructure.kt b/src/main/kotlin/com/github/xepozz/mago/config/reference/ConfigStructure.kt index 6a97a31..c2f14e1 100644 --- a/src/main/kotlin/com/github/xepozz/mago/config/reference/ConfigStructure.kt +++ b/src/main/kotlin/com/github/xepozz/mago/config/reference/ConfigStructure.kt @@ -1,21 +1,43 @@ package com.github.xepozz.mago.config.reference object ConfigStructure { + /** Section names that use TOML array-of-tables syntax `[[section]]` and can be defined multiple times. */ + val SECTIONS_WITH_DOUBLE_BRACKETS = setOf("guard.perimeter.rules", "guard.structural.rules") + + /** + * Section and key names aligned with mago config docs (configuration.md, tool config references). + * We keep this in addition to `mago config --schema` because: + * - The schema drives rich completions (descriptions, examples) via JSON Schema when available. + * - This list is used for section/key completion and references when the schema is not yet loaded, + * and to insert proper TOML section headers (`[section]` or `[[section]]`) when completing at top level. + */ val STRUCTURE = mapOf( "source" to listOf( - "path", + "paths", "includes", "excludes", + "extensions", + ), + "parser" to listOf( + "enable-short-tags", ), "formatter" to listOf( + "preset", + "excludes", "print-width", "tab-width", "use-tabs", + "end-of-line", + "single-quote", + "trailing-comma", + "indent-heredoc", + "remove-trailing-close-tag", ), "analyzer" to listOf( "excludes", - "baseline", "ignore", + "baseline", + "baseline-variant", *AnalyzerOptions.OPTIONS.toTypedArray(), *AnalyzerFeatureFlags.FEATURES.toTypedArray(), ), @@ -23,14 +45,21 @@ object ConfigStructure { "excludes", "integrations", "baseline", + "baseline-variant", ), "linter.rules" to listOf( "ambiguous-function-call", "literal-named-argument", "halstead", + "prefer-static-closure", + "no-else-clause", + "cyclomatic-complexity", ), "guard" to listOf( + "mode", "excludes", + "baseline", + "baseline-variant", ), "guard.perimeter" to listOf( "layering", @@ -44,6 +73,7 @@ object ConfigStructure { "on", "not-on", "target", + "must-be", "must-be-named", "must-be-final", "must-be-abstract", diff --git a/src/main/kotlin/com/github/xepozz/mago/config/reference/FileSetReferenceContributor.kt b/src/main/kotlin/com/github/xepozz/mago/config/reference/FileSetReferenceContributor.kt index f3f5e16..7f794e9 100644 --- a/src/main/kotlin/com/github/xepozz/mago/config/reference/FileSetReferenceContributor.kt +++ b/src/main/kotlin/com/github/xepozz/mago/config/reference/FileSetReferenceContributor.kt @@ -37,9 +37,9 @@ class FileSetReferenceContributor : PsiReferenceContributor() { PlatformPatterns.string().oneOf( "[source]", "[guard]", - "[excludes]", "[linter]", "[analyzer]", + "[formatter]", ) ) ) @@ -69,6 +69,8 @@ class FileSetReferenceContributor : PsiReferenceContributor() { .withText( PlatformPatterns.string().oneOf( "[analyzer]", + "[linter]", + "[guard]", ) ) ) diff --git a/src/main/kotlin/com/github/xepozz/mago/config/reference/LayerReference.kt b/src/main/kotlin/com/github/xepozz/mago/config/reference/LayerReference.kt index 2d6d3dd..fd318e1 100644 --- a/src/main/kotlin/com/github/xepozz/mago/config/reference/LayerReference.kt +++ b/src/main/kotlin/com/github/xepozz/mago/config/reference/LayerReference.kt @@ -30,7 +30,7 @@ class LayerReference( override fun getVariants(): Array { return MagoLayersIndexUtil.getAll(element.project) - .map { + .map { it -> val namespaces = it.value.flatMap { it.values }.joinToString { "," } LookupElementBuilder.create(it.key) .withIcon(MagoIcons.MAGO) @@ -38,4 +38,4 @@ class LayerReference( } .toTypedArray() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/xepozz/mago/config/reference/LayersListReference.kt b/src/main/kotlin/com/github/xepozz/mago/config/reference/LayersListReference.kt index 5f36d39..062d8e6 100644 --- a/src/main/kotlin/com/github/xepozz/mago/config/reference/LayersListReference.kt +++ b/src/main/kotlin/com/github/xepozz/mago/config/reference/LayersListReference.kt @@ -1,5 +1,6 @@ package com.github.xepozz.mago.config.reference +import com.github.xepozz.mago.MagoBundle import com.github.xepozz.mago.config.index.MagoLayersIndexUtil import com.intellij.codeInsight.completion.DeclarativeInsertHandler import com.intellij.codeInsight.completion.SingleInsertionDeclarativeInsertHandler @@ -31,10 +32,10 @@ class LayersListReference( override fun getVariants(): Array { return arrayOf( LookupElementBuilder.create("@all") - .withTypeText("All layers", true) + .withTypeText(MagoBundle.message("layers.all"), true) .withIcon(AllIcons.Hierarchy.Supertypes), LookupElementBuilder.create("@layer") - .withTypeText("Specific layer", true) + .withTypeText(MagoBundle.message("layers.specific"), true) .withIcon(AllIcons.Hierarchy.Subtypes) .withInsertHandler(SingleInsertionDeclarativeInsertHandler(":", DeclarativeInsertHandler.PopupOptions.MemberLookup)), ) diff --git a/src/main/kotlin/com/github/xepozz/mago/config/reference/LayersReferenceContributor.kt b/src/main/kotlin/com/github/xepozz/mago/config/reference/LayersReferenceContributor.kt index 07ba673..0cfbddb 100644 --- a/src/main/kotlin/com/github/xepozz/mago/config/reference/LayersReferenceContributor.kt +++ b/src/main/kotlin/com/github/xepozz/mago/config/reference/LayersReferenceContributor.kt @@ -60,7 +60,6 @@ class LayersReferenceContributor : PsiReferenceContributor() { } companion object { - private const val ALL_PREFIX = "@all" private const val LAYER_PREFIX = "@layer:" } -} \ No newline at end of file +} 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..ecc5880 100644 --- a/src/main/kotlin/com/github/xepozz/mago/configuration/MagoConfigurable.kt +++ b/src/main/kotlin/com/github/xepozz/mago/configuration/MagoConfigurable.kt @@ -2,16 +2,13 @@ package com.github.xepozz.mago.configuration import com.github.xepozz.mago.MagoBundle import com.github.xepozz.mago.qualityTool.MagoQualityToolType -import com.intellij.codeInsight.daemon.HighlightDisplayKey import com.intellij.icons.AllIcons import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.ShowSettingsUtil -import com.intellij.openapi.options.ex.ConfigurableExtensionPointUtil import com.intellij.openapi.project.Project -import com.intellij.profile.codeInspection.InspectionProfileManager -import com.intellij.profile.codeInspection.ui.ErrorsConfigurable -import com.intellij.profile.codeInspection.ui.ErrorsConfigurableProvider +import com.intellij.openapi.ui.TextFieldWithBrowseButton +import com.intellij.ui.ToolbarDecorator import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBLabel import com.intellij.ui.components.OnOffButton @@ -20,134 +17,234 @@ import com.intellij.ui.dsl.builder.RowLayout import com.intellij.ui.dsl.builder.bindSelected import com.intellij.ui.dsl.builder.bindText import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.AbstractTableCellEditor +import com.intellij.util.ui.ColumnInfo +import com.intellij.util.ui.ListTableModel import com.jetbrains.php.PhpBundle -import com.jetbrains.php.lang.inspections.PhpInspectionsUtil import com.jetbrains.php.tools.quality.QualityToolConfigurationComboBox import com.jetbrains.php.tools.quality.QualityToolsIgnoreFilesConfigurable -import java.awt.event.ActionEvent +import java.awt.Component +import java.awt.Dimension import java.awt.event.ActionListener import javax.swing.JComponent +import javax.swing.JTable import javax.swing.SwingConstants class MagoConfigurable(val project: Project) : Configurable { - val settings = project.getService(MagoProjectConfiguration::class.java) - val inspectionProfileManager = InspectionProfileManager.getInstance(project) + private val settings = MagoProjectConfiguration.getInstance(project) - val qualityToolConfigurationComboBox = QualityToolConfigurationComboBox(project, getQualityToolType()) - var myPanel = panel { + private val qualityToolConfigurationComboBox = + QualityToolConfigurationComboBox(project, MagoQualityToolType.INSTANCE) + + private val workspaceMappingsModel: ListTableModel = ListTableModel( + object : ColumnInfo(MagoBundle.message("settings.workspaceMappings.column.workspace")) { + override fun valueOf(item: MagoWorkspaceMapping) = item.workspace + override fun setValue(item: MagoWorkspaceMapping, value: String) { + item.workspace = value + } + + override fun isCellEditable(item: MagoWorkspaceMapping) = true + }, + object : ColumnInfo( + MagoBundle.message("settings.workspaceMappings.column.configFile") + ) { + override fun valueOf(item: MagoWorkspaceMapping) = item.configFile + override fun setValue(item: MagoWorkspaceMapping, value: String) { + item.configFile = value + } + + override fun isCellEditable(item: MagoWorkspaceMapping) = true + } + ) + + private val workspaceMappingsTable = JBTable(workspaceMappingsModel).apply { + setRowHeight(30) + columnModel.getColumn(0).cellEditor = PathCellEditor( + FileChooserDescriptorFactory.createSingleFolderDescriptor() + ) + columnModel.getColumn(1).cellEditor = PathCellEditor( + FileChooserDescriptorFactory.createSingleFileDescriptor("toml") + ) + } + + private val workspaceMappingsPanel = ToolbarDecorator.createDecorator(workspaceMappingsTable) + .setAddAction { + workspaceMappingsModel.addRow(MagoWorkspaceMapping()) + val row = workspaceMappingsModel.rowCount - 1 + workspaceMappingsTable.editCellAt(row, 0) + } + .setRemoveAction { + val row = workspaceMappingsTable.selectedRow + if (row >= 0) workspaceMappingsModel.removeRow(row) + } + .createPanel() + .apply { preferredSize = Dimension(0, 150) } + + private inner class PathCellEditor( + descriptor: com.intellij.openapi.fileChooser.FileChooserDescriptor + ) : AbstractTableCellEditor() { + private val field = TextFieldWithBrowseButton() + + init { + field.addBrowseFolderListener(project, descriptor) + } + + override fun getCellEditorValue(): Any = field.text + + override fun getTableCellEditorComponent( + table: JTable, value: Any?, isSelected: Boolean, row: Int, column: Int + ): Component { + field.text = value?.toString() ?: "" + return field + } + } + + private var myPanel = panel { row { - browserLink("Download Mago", "https://github.com/carthage-software/mago") - browserLink("Documentation", "https://mago.carthage.software/guide/getting-started") - browserLink("Report bug", "https://github.com/j-plugins/mago-plugin/issues") - browserLink("Request feature", "https://github.com/j-plugins/mago-plugin/issues") + browserLink(MagoBundle.message("settings.link.download"), "https://github.com/carthage-software/mago") + browserLink(MagoBundle.message("settings.link.documentation"), "https://mago.carthage.software/guide/getting-started") + browserLink(MagoBundle.message("settings.link.reportBug"), "https://github.com/j-plugins/mago-plugin/issues") + browserLink(MagoBundle.message("settings.link.requestFeature"), "https://github.com/j-plugins/mago-plugin/issues") - cell(JBLabel("Debug", AllIcons.Toolwindows.ToolWindowDebugger, SwingConstants.RIGHT)) + cell( + JBLabel( + MagoBundle.message("settings.debug.label"), + AllIcons.Toolwindows.ToolWindowDebugger, + SwingConstants.RIGHT + ) + ) .align(AlignX.RIGHT) .resizableColumn() cell(OnOffButton()) .bindSelected(settings::debug) .align(AlignX.RIGHT) } + group(MagoBundle.message("settings.options.title")) { + row { + cell(OnOffButton()) + .label(MagoBundle.message("settings.enabled")) + .bindSelected(settings::enabled) + .comment(MagoBundle.message("settings.enabled.comment")) + }.layout(RowLayout.PARENT_GRID) + row { cell(qualityToolConfigurationComboBox) - .label("Mago executable") + .label(MagoBundle.message("settings.configuration.label")) .align(AlignX.FILL) }.layout(RowLayout.PARENT_GRID) + row { + @Suppress("UnstableApiUsage") textFieldWithBrowseButton(FileChooserDescriptorFactory.singleFile()) .bindText(settings::configurationFile) - .label("Configuration file") + .label(MagoBundle.message("settings.defaultConfigFile.label")) + .comment(MagoBundle.message("settings.defaultConfigFile.comment")) .align(AlignX.FILL) }.layout(RowLayout.PARENT_GRID) + row { cell( ActionLink( PhpBundle.message("guality.tool.configuration.show.ignored.files"), - ActionListener { e: ActionEvent? -> + ActionListener { ShowSettingsUtil.getInstance().editConfigurable( project, - QualityToolsIgnoreFilesConfigurable(getQualityToolType(), project) + QualityToolsIgnoreFilesConfigurable( + MagoQualityToolType.INSTANCE, + project + ) ) }) ) } } + + group(MagoBundle.message("settings.workspaceMappings.title")) { + row { + comment(MagoBundle.message("settings.workspaceMappings.comment")) + } + row { + cell(workspaceMappingsPanel) + .align(AlignX.FILL) + } + } + group(MagoBundle.message("settings.analyzer.title")) { row { - cell( - PhpInspectionsUtil.createPanelWithSettingsLink( - MagoBundle.message("quality.tool.settings.link.inspection", "Mago"), - ErrorsConfigurable::class.java, - { - ConfigurableExtensionPointUtil.createProjectConfigurableForProvider( - project, - ErrorsConfigurableProvider::class.java - ) as ErrorsConfigurable? - }, - { it.selectInspectionTool(getInspectionShortName()) }, - ) - ) - cell(OnOffButton()) - .bindSelected({ - inspectionProfileManager.currentProfile - .isToolEnabled(HighlightDisplayKey.find(getInspectionShortName())) - }, { - inspectionProfileManager.currentProfile - .setToolEnabled(getInspectionShortName(), it) - }) - browserLink("Documentation", "https://mago.carthage.software/tools/analyzer/overview") + browserLink(MagoBundle.message("settings.link.documentation"), "https://mago.carthage.software/tools/analyzer/overview") .align(AlignX.RIGHT) }.layout(RowLayout.PARENT_GRID) + row { textField() - .label("Additional parameters") + .label(MagoBundle.message("settings.additionalParameters")) .bindText(settings::analyzeAdditionalParameters) - .comment("Read more: mago analyze --help") + .comment(MagoBundle.message("settings.analyzer.paramsComment")) .align(AlignX.FILL) }.layout(RowLayout.PARENT_GRID) } + group(MagoBundle.message("settings.formatter.title")) { row { cell(OnOffButton()) .label(MagoBundle.message("settings.enabled")) .bindSelected(settings::formatterEnabled) - browserLink("Documentation", "https://mago.carthage.software/tools/formatter/overview") + browserLink(MagoBundle.message("settings.link.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") + .label(MagoBundle.message("settings.additionalParameters")) .bindText(settings::formatAdditionalParameters) - .comment("Read more: mago fmt --help") + .comment(MagoBundle.message("settings.formatter.paramsComment")) .align(AlignX.FILL) }.layout(RowLayout.PARENT_GRID) } + group(MagoBundle.message("settings.linter.title")) { row { cell(OnOffButton()) .label(MagoBundle.message("settings.enabled")) .bindSelected(settings::linterEnabled) - browserLink("Documentation", "https://mago.carthage.software/tools/linter/overview") + browserLink(MagoBundle.message("settings.link.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(MagoBundle.message("settings.additionalParameters")) + .bindText(settings::lintAdditionalParameters) + .comment(MagoBundle.message("settings.linter.paramsComment")) + .align(AlignX.FILL) + }.layout(RowLayout.PARENT_GRID) } + group(MagoBundle.message("settings.guard.title")) { row { cell(OnOffButton()) .label(MagoBundle.message("settings.enabled")) .bindSelected(settings::guardEnabled) - browserLink("Documentation", "https://mago.carthage.software/tools/guard/overview") + browserLink(MagoBundle.message("settings.link.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(MagoBundle.message("settings.additionalParameters")) + .bindText(settings::guardAdditionalParameters) + .comment(MagoBundle.message("settings.guard.paramsComment")) + .align(AlignX.FILL) + }.layout(RowLayout.PARENT_GRID) } } @@ -156,33 +253,43 @@ class MagoConfigurable(val project: Project) : Configurable { override fun isModified(): Boolean { return myPanel.isModified() || qualityToolConfigurationComboBox.selectedItemId != getSavedSelectedConfigurationId() + || workspaceMappingsChanged() } override fun apply() { updateSelectedConfiguration(qualityToolConfigurationComboBox.selectedItemId) myPanel.apply() + settings.workspaceMappings = workspaceMappingsModel.items + .map { MagoWorkspaceMapping(it.workspace, it.configFile) } + .toMutableList() } - private fun getQualityToolType() = MagoQualityToolType.INSTANCE - override fun reset() { + myPanel.reset() qualityToolConfigurationComboBox.reset(project, getSavedSelectedConfigurationId()) + workspaceMappingsModel.items = settings.workspaceMappings + .map { MagoWorkspaceMapping(it.workspace, it.configFile) } } - private fun getInspectionShortName() = getQualityToolType().getInspectionShortName(project) - - override fun getDisplayName() = getQualityToolType().getDisplayName() + override fun getDisplayName() = MagoBundle.message("mago.title") private fun updateSelectedConfiguration(newConfigurationId: String?) { - val projectConfiguration = getQualityToolType().getProjectConfiguration(project) + val projectConfiguration = MagoQualityToolType.INSTANCE.getProjectConfiguration(project) if (newConfigurationId != projectConfiguration.selectedConfigurationId) { projectConfiguration.selectedConfigurationId = newConfigurationId } } private fun getSavedSelectedConfigurationId(): String? { - return getQualityToolType().getProjectConfiguration(project).selectedConfigurationId + return MagoQualityToolType.INSTANCE.getProjectConfiguration(project).selectedConfigurationId } + private fun workspaceMappingsChanged(): Boolean { + val current = workspaceMappingsModel.items + val saved = settings.workspaceMappings + if (current.size != saved.size) return true + return current.zip(saved).any { (c, s) -> + c.workspace != s.workspace || c.configFile != s.configFile + } + } } - diff --git a/src/main/kotlin/com/github/xepozz/mago/configuration/MagoConfigurableForm.kt b/src/main/kotlin/com/github/xepozz/mago/configuration/MagoConfigurableForm.kt index 6920a33..50f4058 100644 --- a/src/main/kotlin/com/github/xepozz/mago/configuration/MagoConfigurableForm.kt +++ b/src/main/kotlin/com/github/xepozz/mago/configuration/MagoConfigurableForm.kt @@ -1,5 +1,6 @@ package com.github.xepozz.mago.configuration +import com.github.xepozz.mago.MagoBundle import com.github.xepozz.mago.configuration.MagoConfigurationBaseManager.Companion.MAGO import com.github.xepozz.mago.qualityTool.MagoCustomOptionsForm import com.github.xepozz.mago.qualityTool.MagoQualityToolType @@ -35,7 +36,7 @@ class MagoConfigurableForm(project: Project, configuration: MagoConfiguration) : val regex = Regex("^mago (?.+)$") return regex.find(message)?.groups?.get("version") - ?.let { Pair.create(true, "OK, Mago version ${it.value}") } + ?.let { Pair.create(true, MagoBundle.message("validation.versionOk", it.value)) } ?: Pair.create(false, PhpBundle.message("quality.tool.can.not.determine.version", message)) } } 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..84c28ae 100644 --- a/src/main/kotlin/com/github/xepozz/mago/configuration/MagoProjectConfiguration.kt +++ b/src/main/kotlin/com/github/xepozz/mago/configuration/MagoProjectConfiguration.kt @@ -8,18 +8,29 @@ import com.intellij.openapi.components.Storage import com.intellij.openapi.components.StoragePathMacros import com.intellij.openapi.project.Project import com.intellij.util.xmlb.XmlSerializerUtil +import com.jetbrains.php.config.interpreters.PhpInterpreter +import com.jetbrains.php.config.interpreters.PhpInterpretersManagerImpl import com.jetbrains.php.tools.quality.QualityToolProjectConfiguration @Service(Service.Level.PROJECT) @State(name = "MagoProjectConfiguration", storages = [Storage(StoragePathMacros.WORKSPACE_FILE)]) class MagoProjectConfiguration : QualityToolProjectConfiguration(), PersistentStateComponent { + + var enabled: Boolean = true + var guardEnabled = false var linterEnabled = false var formatterEnabled = true + var analyzeAdditionalParameters = "" + var lintAdditionalParameters = "" + var guardAdditionalParameters = "" var formatAdditionalParameters = "" + var formatAfterFix = false + var configurationFile = "" + var workspaceMappings: MutableList = mutableListOf() var debug = false override fun getState() = this @@ -30,8 +41,29 @@ class MagoProjectConfiguration : QualityToolProjectConfiguration( + val dialog = QualityToolByInterpreterDialog( project, existingSettings, MagoConfigurationBaseManager.MAGO, diff --git a/src/main/kotlin/com/github/xepozz/mago/execution/LocalMagoRunner.kt b/src/main/kotlin/com/github/xepozz/mago/execution/LocalMagoRunner.kt new file mode 100644 index 0000000..8726cb6 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/execution/LocalMagoRunner.kt @@ -0,0 +1,55 @@ +package com.github.xepozz.mago.execution + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.CapturingProcessHandler +import com.intellij.execution.process.ProcessOutput +import com.intellij.openapi.project.Project +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.concurrent.TimeUnit + +class LocalMagoRunner : MagoRunner { + override fun run( + project: Project, + exePath: String, + args: List, + timeoutMs: Int, + workDir: File? + ): ProcessOutput { + val cmd = GeneralCommandLine(exePath).withParameters(args) + workDir?.let { cmd.withWorkDirectory(it) } + return CapturingProcessHandler(cmd).runProcess(timeoutMs) + } + + override fun runWithStdin( + project: Project, + exePath: String, + args: List, + stdinContent: String, + timeoutMs: Int, + workDir: File? + ): ProcessOutput { + val command = listOf(exePath) + args + val pb = ProcessBuilder(command).redirectInput(ProcessBuilder.Redirect.PIPE) + workDir?.let { pb.directory(it) } + val process = pb.start() + val stdinBytes = stdinContent.toByteArray(Charsets.UTF_8) + Thread { + try { + process.outputStream.use { it.write(stdinBytes) } + } catch (_: Exception) { + } + }.apply { start(); join(timeoutMs.coerceAtLeast(1000).toLong()) } + val stdout = ByteArrayOutputStream() + val stderr = ByteArrayOutputStream() + Thread { process.inputStream.transferTo(stdout) }.start() + Thread { process.errorStream.transferTo(stderr) }.start() + val finished = process.waitFor(timeoutMs.coerceAtLeast(1000).toLong(), TimeUnit.MILLISECONDS) + if (!finished) process.destroyForcibly() + return ProcessOutput().apply { + appendStdout(stdout.toString(Charsets.UTF_8.name())) + appendStderr(stderr.toString(Charsets.UTF_8.name())) + exitCode = if (finished) process.exitValue() else -1 + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/execution/MagoRunner.kt b/src/main/kotlin/com/github/xepozz/mago/execution/MagoRunner.kt new file mode 100644 index 0000000..9b8af12 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/execution/MagoRunner.kt @@ -0,0 +1,37 @@ +package com.github.xepozz.mago.execution + +import com.intellij.execution.process.ProcessOutput +import com.intellij.openapi.project.Project +import java.io.File + +interface MagoRunner { + /** + * @param workDir When set, the process runs with this as the current working directory. + * This ensures relative paths in config (e.g.: baseline file) resolve correctly. + */ + fun run( + project: Project, + exePath: String, + args: List, + timeoutMs: Int = 30_000, + workDir: File? = null + ): ProcessOutput + + /** + * Run mago with [stdinContent] as standard input (e.g.: for --stdin-input). + * Used for editor integration so baselines use the real file path. + * If the runner cannot support stdin (e.g.: older Mago version), it may return a failed ProcessOutput + * so the caller can fall back to the temp-file method. + * + * @param workDir When set, the process runs with this as the current working directory. + * This ensures relative paths in config (e.g.: baseline file) resolve correctly. + */ + fun runWithStdin( + project: Project, + exePath: String, + args: List, + stdinContent: String, + timeoutMs: Int = 30_000, + workDir: File? = null + ): ProcessOutput = run(project, exePath, args, timeoutMs, workDir) +} diff --git a/src/main/kotlin/com/github/xepozz/mago/execution/RemoteInterpreterMagoRunner.kt b/src/main/kotlin/com/github/xepozz/mago/execution/RemoteInterpreterMagoRunner.kt new file mode 100644 index 0000000..a022a58 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/execution/RemoteInterpreterMagoRunner.kt @@ -0,0 +1,89 @@ +package com.github.xepozz.mago.execution + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.ProcessOutput +import com.intellij.openapi.project.Project +import com.jetbrains.php.config.interpreters.PhpInterpreter +import com.jetbrains.php.run.remote.PhpRemoteInterpreterManager +import java.io.File +import java.util.Base64 +import javax.swing.JPanel + +class RemoteInterpreterMagoRunner(private val interpreter: PhpInterpreter) : MagoRunner { + override fun run( + project: Project, + exePath: String, + args: List, + timeoutMs: Int, + workDir: File? + ): ProcessOutput { + val (manager, mappedArgs) = mapPathsAndGetManager(project, args) ?: return errorOutput( + PhpRemoteInterpreterManager.getRemoteInterpreterPluginIsDisabledErrorMessage() + ) + val cmdLine = GeneralCommandLine(exePath).withParameters(mappedArgs) + return manager.getProcessOutput(project, interpreter.phpSdkAdditionalData, cmdLine, "", JPanel()) + } + + override fun runWithStdin( + project: Project, + exePath: String, + args: List, + stdinContent: String, + timeoutMs: Int, + workDir: File? + ): ProcessOutput { + val (manager, mappedArgs) = mapPathsAndGetManager(project, args) ?: return errorOutput( + PhpRemoteInterpreterManager.getRemoteInterpreterPluginIsDisabledErrorMessage() + ) + // Hack: the PHP Docker plugin never sends stdin and never closes it, so mago would hang. + // Run sh -c 'printf "%s" "$..." | base64 -d | mago ...' with content in env. + // Use a name mago won't treat as config (mago maps MAGO_* env vars to config keys). + val stdinB64 = Base64.getEncoder().encodeToString(stdinContent.toByteArray(Charsets.UTF_8)) + val pipeline = buildShellPipeline(exePath, mappedArgs) + val cmdLine = GeneralCommandLine("/bin/sh") + .withParameters("-c", pipeline) + .withEnvironment("PLUGIN_STDIN_B64", stdinB64) + return manager.getProcessOutput(project, interpreter.phpSdkAdditionalData, cmdLine, "", JPanel()) + } + + /** Builds sh -c pipeline: printf '%s' "$PLUGIN_STDIN_B64" | base64 -d | */ + private fun buildShellPipeline(exePath: String, args: List): String { + val quotedArgs = args.joinToString(" ") { arg -> + "'" + arg.replace("'", "'\"'\"'") + "'" + } + return $$"printf '%s' \"$PLUGIN_STDIN_B64\" | base64 -d | $$exePath $$quotedArgs" + } + + /** + * Maps all host paths in [args] to remote paths using the PHP plugin's path mapper. + * Handles both bare paths and --key=path args (e.g. --workspace=, --config=) so that + * remote interpreters (e.g.: Docker with a different mount like /opt/project) receive + * the correct paths. + */ + private fun mapPathsAndGetManager(project: Project, args: List): Pair>? { + val manager = PhpRemoteInterpreterManager.getInstance() ?: return null + val pathProcessor = manager.createPathMapper(project, interpreter.phpSdkAdditionalData) + val mappedArgs = args.map { arg -> + when { + arg.startsWith("--workspace=") -> { + val path = arg.removePrefix("--workspace=") + if (pathProcessor.canProcess(path)) "--workspace=${pathProcessor.process(path)}" else arg + } + arg.startsWith("--config=") -> { + val path = arg.removePrefix("--config=") + if (pathProcessor.canProcess(path)) "--config=${pathProcessor.process(path)}" else arg + } + pathProcessor.canProcess(arg) -> pathProcessor.process(arg) + else -> arg + } + } + return manager to mappedArgs + } + + private fun errorOutput(message: String, exitCode: Int = -1): ProcessOutput { + return ProcessOutput().also { + it.appendStderr(message) + if (exitCode >= 0) it.exitCode = exitCode + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/file/MagoTextFileType.kt b/src/main/kotlin/com/github/xepozz/mago/file/MagoTextFileType.kt index ce31590..f05f65b 100644 --- a/src/main/kotlin/com/github/xepozz/mago/file/MagoTextFileType.kt +++ b/src/main/kotlin/com/github/xepozz/mago/file/MagoTextFileType.kt @@ -1,21 +1,25 @@ package com.github.xepozz.mago.file +import com.github.xepozz.mago.MagoBundle import com.github.xepozz.mago.MagoIcons import com.intellij.openapi.fileTypes.LanguageFileType import com.intellij.openapi.fileTypes.PlainTextLanguage import java.io.Serializable class MagoTextFileType private constructor() : LanguageFileType(PlainTextLanguage.INSTANCE), Serializable { - override fun getName() = "Mago File" + companion object { + @JvmField + val INSTANCE = MagoTextFileType() + + /** Must match plugin.xml fileType name (bundle key fileType.name default) and MagoBundle.fileType.name. */ + const val NAME = "Mago File" + } + + override fun getName() = NAME - override fun getDescription() = "Mago configuration file" + override fun getDescription() = MagoBundle.message("fileType.description") override fun getDefaultExtension() = "toml" override fun getIcon() = MagoIcons.MAGO - - companion object { - @JvmStatic - val INSTANCE = MagoTextFileType() - } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/xepozz/mago/file/MagoTomlFileType.kt b/src/main/kotlin/com/github/xepozz/mago/file/MagoTomlFileType.kt index d1aaa4f..73090fc 100644 --- a/src/main/kotlin/com/github/xepozz/mago/file/MagoTomlFileType.kt +++ b/src/main/kotlin/com/github/xepozz/mago/file/MagoTomlFileType.kt @@ -1,21 +1,22 @@ package com.github.xepozz.mago.file +import com.github.xepozz.mago.MagoBundle import com.github.xepozz.mago.MagoIcons import com.intellij.openapi.fileTypes.LanguageFileType import org.toml.lang.TomlLanguage import java.io.Serializable -class MagoTomlFileType private constructor(): LanguageFileType(TomlLanguage), Serializable { - override fun getName() = "Mago File" +class MagoTomlFileType private constructor() : LanguageFileType(TomlLanguage), Serializable { + companion object { + @JvmField + val INSTANCE = MagoTomlFileType() + } + + override fun getName() = MagoTextFileType.NAME - override fun getDescription() = "Mago configuration file" + override fun getDescription() = MagoBundle.message("fileType.description") override fun getDefaultExtension() = "toml" override fun getIcon() = MagoIcons.MAGO - - companion object { - @JvmStatic - val INSTANCE = MagoTomlFileType() - } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/xepozz/mago/formatter/Directives.kt b/src/main/kotlin/com/github/xepozz/mago/formatter/Directives.kt index 72b9652..4e01d2b 100644 --- a/src/main/kotlin/com/github/xepozz/mago/formatter/Directives.kt +++ b/src/main/kotlin/com/github/xepozz/mago/formatter/Directives.kt @@ -27,4 +27,4 @@ enum class Directives(val directive: String,val description: String) { `use-compound-assignment`("use-compound-assignment", "Use Compound Assignment"), `use-wp-functions`("use-wp-functions", "Use WordPress API Functions"), `yoda-conditions`("yoda-conditions", "Yoda Conditions"), -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/xepozz/mago/formatter/MagoExternalFormatter.kt b/src/main/kotlin/com/github/xepozz/mago/formatter/MagoExternalFormatter.kt index ddb90fb..f3b2154 100644 --- a/src/main/kotlin/com/github/xepozz/mago/formatter/MagoExternalFormatter.kt +++ b/src/main/kotlin/com/github/xepozz/mago/formatter/MagoExternalFormatter.kt @@ -1,46 +1,120 @@ package com.github.xepozz.mago.formatter +import com.github.xepozz.mago.MagoBundle +import com.github.xepozz.mago.analysis.MagoCliOptions import com.github.xepozz.mago.configuration.MagoProjectConfiguration -import com.intellij.openapi.application.ReadAction -import com.intellij.openapi.diagnostic.thisLogger -import com.intellij.openapi.progress.ProgressManager -import com.intellij.openapi.util.TextRange +import com.github.xepozz.mago.execution.LocalMagoRunner +import com.github.xepozz.mago.execution.MagoRunner +import com.github.xepozz.mago.execution.RemoteInterpreterMagoRunner +import com.github.xepozz.mago.utils.DebugLogger +import com.intellij.formatting.service.AsyncDocumentFormattingService +import com.intellij.openapi.project.Project +import com.intellij.formatting.service.AsyncFormattingRequest +import com.intellij.formatting.service.FormattingService +import com.intellij.openapi.util.io.FileUtil import com.intellij.psi.PsiFile -import com.intellij.psi.codeStyle.ExternalFormatProcessor import com.jetbrains.php.lang.PhpFileType +import java.io.File -class MagoExternalFormatter : ExternalFormatProcessor { - override fun activeForFile(source: PsiFile): Boolean { - val project = source.project +class MagoExternalFormatter : AsyncDocumentFormattingService() { + override fun getFeatures(): Set = emptySet() + + override fun canFormat(file: PsiFile): Boolean { + val project = file.project if (project.isDisposed) return false - val projectConfiguration = project.getService(MagoProjectConfiguration::class.java) - if (!projectConfiguration.formatterEnabled) return false + val settings = MagoProjectConfiguration.getInstance(project) + if (!settings.formatterEnabled) return false + + val exe = settings.getEffectiveToolPath(project) + if (exe.isBlank()) return false - return source.fileType == PhpFileType.INSTANCE + return file.fileType == PhpFileType.INSTANCE } - override fun format( - source: PsiFile, - range: TextRange, - canChangeWhiteSpacesOnly: Boolean, - keepLineBreaks: Boolean, - enableBulkUpdate: Boolean, - cursorOffset: Int - ): TextRange? { - val virtualFile = source.originalFile.virtualFile ?: return null + override fun getNotificationGroupId(): String = "Mago" - thisLogger().debug("Reformatting file: ${virtualFile.path}") - ProgressManager.checkCanceled() -// println("before: ${source.text}") + override fun getName(): String = MagoBundle.message("formatter.name") - val project = source.project - ReadAction.run { MagoReformatFile(project).invoke(project, source) } + override fun createFormattingTask(request: AsyncFormattingRequest): FormattingTask? { + val context = request.context + val project = context.project + val virtualFile = context.virtualFile ?: return null + val settings = MagoProjectConfiguration.getInstance(project) - return null - } + val (runner, exe) = chooseRunnerAndExe(project, settings) + if (exe.isBlank()) return null + + return object : FormattingTask { + @Volatile + private var cancelled = false + + override fun run() { + if (cancelled) return + + val parentDir = virtualFile.parent?.path + if (parentDir == null) { + request.onError(MagoBundle.message("formatter.name"), MagoBundle.message("formatter.error.noParentDir")) + return + } + + val tempFile = File.createTempFile( + ".mago-fmt-", + ".php", + File(parentDir) + ) + tempFile.deleteOnExit() + try { + tempFile.writeText(request.documentText, Charsets.UTF_8) + + val tempPath = FileUtil.toSystemIndependentName(tempFile.absolutePath) + val args = MagoCliOptions.getFormatOptions(settings, project, listOf(tempPath)) - override fun indent(source: PsiFile, lineStartOffset: Int) = null + DebugLogger.inform( + project, + title = MagoBundle.message("formatter.title"), + content = "File: ${virtualFile.path}
" + + "Executable: $exe

" + + "Format options: ${args.joinToString(" ")}" + ) - override fun getId() = "Mago" -} \ No newline at end of file + val output = runner.run(project, exe, args) + + if (cancelled) return + + if (output.exitCode != 0) { + val stderr = output.stderr.trim() + request.onError( + MagoBundle.message("formatter.error.title"), + stderr.ifBlank { MagoBundle.message("formatter.error.exitCode", output.exitCode) } + ) + return + } + + val formattedText = tempFile.readText(Charsets.UTF_8) + request.onTextReady(formattedText) + } catch (e: Exception) { + request.onError(MagoBundle.message("formatter.error.generic"), e.message ?: MagoBundle.message("formatter.error.unknown")) + } finally { + tempFile.delete() + } + } + + override fun cancel(): Boolean { + cancelled = true + return true + } + + override fun isRunUnderProgress(): Boolean = true + } + } + + private fun chooseRunnerAndExe(project: Project, settings: MagoProjectConfiguration): Pair { + val interpreter = settings.resolveInterpreter(project) + return if (interpreter != null && interpreter.isRemote) { + RemoteInterpreterMagoRunner(interpreter) to settings.getEffectiveToolPath(project) + } else { + LocalMagoRunner() to settings.getEffectiveToolPath(project) + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/formatter/MagoReformatFile.kt b/src/main/kotlin/com/github/xepozz/mago/formatter/MagoReformatFile.kt deleted file mode 100644 index 0d5c65e..0000000 --- a/src/main/kotlin/com/github/xepozz/mago/formatter/MagoReformatFile.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.github.xepozz.mago.formatter - -import com.github.xepozz.mago.MagoBundle -import com.github.xepozz.mago.configuration.MagoProjectConfiguration -import com.github.xepozz.mago.qualityTool.MagoAnnotatorProxy -import com.github.xepozz.mago.qualityTool.MagoQualityToolType -import com.intellij.openapi.project.Project -import com.intellij.openapi.vfs.VirtualFile -import com.jetbrains.php.config.commandLine.PhpCommandSettings -import com.jetbrains.php.tools.quality.QualityToolConfiguration -import com.jetbrains.php.tools.quality.QualityToolReformatFile -import com.jetbrains.php.tools.quality.QualityToolValidationException - -class MagoReformatFile(val project: Project) : QualityToolReformatFile() { - val settings = project.getService(MagoProjectConfiguration::class.java) - - override fun getQualityToolType() = MagoQualityToolType.INSTANCE - - override fun fillArguments(options: MutableList, command: PhpCommandSettings, workDirectory: String?) { - for (i in 0..): List { - val files = virtualFiles.map { it.path } - val settings = project.getService(MagoProjectConfiguration::class.java) - - return MagoAnnotatorProxy.getFormatOptions(settings, project, files) - } -} diff --git a/src/main/kotlin/com/github/xepozz/mago/formatter/MagoReformatFileAction.kt b/src/main/kotlin/com/github/xepozz/mago/formatter/MagoReformatFileAction.kt deleted file mode 100644 index 069c943..0000000 --- a/src/main/kotlin/com/github/xepozz/mago/formatter/MagoReformatFileAction.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.github.xepozz.mago.formatter - -//class MagoReformatFileAction : QualityToolReformatFileAction(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/intentions/MagoNavigateToRelatedAction.kt b/src/main/kotlin/com/github/xepozz/mago/intentions/MagoNavigateToRelatedAction.kt new file mode 100644 index 0000000..caa0666 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/intentions/MagoNavigateToRelatedAction.kt @@ -0,0 +1,51 @@ +package com.github.xepozz.mago.intentions + +import com.github.xepozz.mago.MagoBundle +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.codeInsight.intention.PriorityAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.ScrollType +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.psi.PsiFile + +class MagoNavigateToRelatedAction( + private val message: String, + private val targetOffset: Int, + private val line: Int, + private val targetFilePath: String? = null, +) : IntentionAction, PriorityAction { + + override fun getText(): String = MagoBundle.message("intention.navigateToCause", message, line) + override fun getFamilyName(): String = MagoBundle.message("intention.familyName") + + override fun getPriority(): PriorityAction.Priority = PriorityAction.Priority.NORMAL + + override fun startInWriteAction(): Boolean = false + override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean = true + + override fun invoke(project: Project, editor: Editor?, file: PsiFile?) { + val isOtherFile = targetFilePath != null && file?.virtualFile?.path != targetFilePath + + if (isOtherFile) { + val vf = LocalFileSystem.getInstance().findFileByPath(targetFilePath) + if (vf != null) { + val opened = FileEditorManager.getInstance(project).openFile(vf, true) + if (opened.isNotEmpty()) { + val targetEditor = FileEditorManager.getInstance(project).selectedTextEditor + if (targetEditor != null) { + val doc = targetEditor.document + val lineStart = if (line < doc.lineCount) doc.getLineStartOffset(line) else 0 + targetEditor.caretModel.moveToOffset(lineStart) + targetEditor.scrollingModel.scrollToCaret(ScrollType.CENTER) + } + } + } + } else { + editor ?: return + editor.caretModel.moveToOffset(targetOffset) + editor.scrollingModel.scrollToCaret(ScrollType.CENTER) + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/intentions/MagoRemoveRedundantFileAction.kt b/src/main/kotlin/com/github/xepozz/mago/intentions/MagoRemoveRedundantFileAction.kt new file mode 100644 index 0000000..27efb13 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/intentions/MagoRemoveRedundantFileAction.kt @@ -0,0 +1,32 @@ +package com.github.xepozz.mago.intentions + +import com.github.xepozz.mago.MagoBundle +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.codeInsight.intention.PriorityAction +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiFile + +/** + * Quick fix for [lint:no-redundant-file]: removes the current file when it has no executable code or declarations. + */ +class MagoRemoveRedundantFileAction : IntentionAction, PriorityAction { + override fun getText(): String = MagoBundle.message("intention.removeFile") + override fun getFamilyName(): String = MagoBundle.message("intention.familyName") + + override fun getPriority(): PriorityAction.Priority = PriorityAction.Priority.HIGH + + override fun startInWriteAction(): Boolean = true + + override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean = file?.virtualFile != null + + override fun invoke(project: Project, editor: Editor?, file: PsiFile?) { + val virtualFile = file?.virtualFile ?: return + WriteCommandAction.runWriteCommandAction(project) { + FileEditorManager.getInstance(project).closeFile(virtualFile) + virtualFile.delete(project) + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/intentions/apply/ApplyAllScope.kt b/src/main/kotlin/com/github/xepozz/mago/intentions/apply/ApplyAllScope.kt new file mode 100644 index 0000000..0fb8d0a --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/intentions/apply/ApplyAllScope.kt @@ -0,0 +1,11 @@ +package com.github.xepozz.mago.intentions.apply + +import com.github.xepozz.mago.MagoBundle + +enum class ApplyAllScope(val maxSafetyLevel: Int, private val labelKey: String) { + SAFE_ONLY(maxSafetyLevel = 0, labelKey = "apply.scope.safeOnly"), + POTENTIALLY_UNSAFE(maxSafetyLevel = 1, labelKey = "apply.scope.potentiallyUnsafe"), + UNSAFE(maxSafetyLevel = 2, labelKey = "apply.scope.unsafe"); + + val label: String get() = MagoBundle.message(labelKey) +} diff --git a/src/main/kotlin/com/github/xepozz/mago/intentions/apply/ApplyEditUtils.kt b/src/main/kotlin/com/github/xepozz/mago/intentions/apply/ApplyEditUtils.kt new file mode 100644 index 0000000..4d961c4 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/intentions/apply/ApplyEditUtils.kt @@ -0,0 +1,35 @@ +package com.github.xepozz.mago.intentions.apply + +import com.github.xepozz.mago.model.MagoEdit +import com.intellij.openapi.util.io.FileUtil +import java.nio.file.Paths + +fun safetyLevel(safety: String): Int = when (safety) { + "unsafe" -> 2 + "potentiallyunsafe" -> 1 + else -> 0 +} + +fun MagoEdit.maxSafetyLevel(): Int = replacements.maxOfOrNull { safetyLevel(it.safety) } ?: 0 + +/** Normalize the path for comparison so the edit path (e.g.: with ./) matches the IDE path. */ +internal fun normalizePath(path: String): String = FileUtil.toCanonicalPath(path) + +/** True if this edit applies to the given file (path or name match, paths normalized). */ +internal 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() } diff --git a/src/main/kotlin/com/github/xepozz/mago/intentions/apply/MagoApplyEditAction.kt b/src/main/kotlin/com/github/xepozz/mago/intentions/apply/MagoApplyEditAction.kt new file mode 100644 index 0000000..7183b35 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/intentions/apply/MagoApplyEditAction.kt @@ -0,0 +1,111 @@ +package com.github.xepozz.mago.intentions.apply + +import com.github.xepozz.mago.MagoBundle +import com.github.xepozz.mago.configuration.MagoProjectConfiguration +import com.github.xepozz.mago.model.MagoEdit +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer +import com.intellij.codeInsight.intention.FileModifier +import com.intellij.codeInsight.intention.IntentionAction +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.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.codeStyle.CodeStyleManager +import java.nio.charset.StandardCharsets + +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() = MagoBundle.message("intention.familyName") + + override fun getPriority(): PriorityAction.Priority { + return if (isApplyAll) PriorityAction.Priority.LOW else PriorityAction.Priority.HIGH + } + + override fun getText(): String { + if (applyAllScope != null) { + return MagoBundle.message("intention.apply.allWithScope", applyAllScope.label) + } + val maxSafetyValue = edits.flatMap { it.replacements }.maxOfOrNull { safetyLevel(it.safety) } ?: 0 + val safetySuffix = when (maxSafetyValue) { + 2 -> MagoBundle.message("intention.apply.suffixUnsafe") + 1 -> MagoBundle.message("intention.apply.suffixPotentiallyUnsafe") + else -> "" + } + return when { + !fixDescription.isNullOrBlank() -> MagoBundle.message( + "intention.apply.withDescription", + fixDescription.trim() + ) + safetySuffix + + isApplyAll -> MagoBundle.message("intention.apply.all") + safetySuffix + else -> MagoBundle.message("intention.apply.one") + 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) + FileDocumentManager.getInstance().saveDocument(doc) + + val settings = project.getService(MagoProjectConfiguration::class.java) + if (settings.formatAfterFix && settings.formatterEnabled) { + CodeStyleManager.getInstance(project) + .reformatText(file, 0, doc.textLength) + } + DaemonCodeAnalyzer.getInstance(project).restart(file) + } + } + } + + 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() +} diff --git a/src/main/kotlin/com/github/xepozz/mago/intentions/apply/MagoApplyEditSubmenuAction.kt b/src/main/kotlin/com/github/xepozz/mago/intentions/apply/MagoApplyEditSubmenuAction.kt new file mode 100644 index 0000000..deead9d --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/intentions/apply/MagoApplyEditSubmenuAction.kt @@ -0,0 +1,47 @@ +package com.github.xepozz.mago.intentions.apply + +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.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile + +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 { + 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( + mainAction = mainCopy, + subActions = subCopies.filterIsInstance() + ) + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoClassSuppressAction.kt b/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoClassSuppressAction.kt new file mode 100644 index 0000000..3e4043a --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoClassSuppressAction.kt @@ -0,0 +1,30 @@ +package com.github.xepozz.mago.intentions.suppress + +import com.github.xepozz.mago.MagoBundle +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiFile +import com.jetbrains.php.lang.psi.elements.PhpClass + +class MagoClassSuppressAction( + category: String, + code: String, + line: Int, + problemStartOffset: Int? = null, + useExpect: Boolean = false +) : MagoIgnoreAction(category, code, line, problemStartOffset, useExpect) { + + override fun getText() = scopeText(MagoBundle.message("intention.scope.class")) + + override fun isAvailable(project: Project, editor: Editor, file: PsiFile): Boolean { + if (!super.isAvailable(project, editor, file)) return false + val clazz = findElement(file, editor.document, PhpClass::class.java, editor) ?: return false + if (getInsertionLine(editor.document, clazz) == line) return false + return !isSuppressionAlreadyPresent(clazz) + } + + override fun invoke(project: Project, editor: Editor, file: PsiFile?) { + if (file == null) return + invokeForElement(project, editor, file, PhpClass::class.java) + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoFunctionSuppressAction.kt b/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoFunctionSuppressAction.kt new file mode 100644 index 0000000..c400a17 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoFunctionSuppressAction.kt @@ -0,0 +1,54 @@ +package com.github.xepozz.mago.intentions.suppress + +import com.github.xepozz.mago.MagoBundle +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.psi.PsiFile +import com.jetbrains.php.lang.psi.elements.Function +import com.jetbrains.php.lang.psi.elements.Method + +class MagoFunctionSuppressAction( + category: String, + code: String, + line: Int, + problemStartOffset: Int? = null, + useExpect: Boolean = false +) : MagoIgnoreAction(category, code, line, problemStartOffset, useExpect) { + + @Suppress("DialogTitleCapitalization") + override fun getText() = scopeText(MagoBundle.message("intention.scope.function")) + + override fun isAvailable(project: Project, editor: Editor, file: PsiFile): Boolean { + if (!super.isAvailable(project, editor, file)) return false + val document = editor.document + // Only show "for function" when we're in a standalone function; inside a method show only "for method" + if (findElement(file, document, Method::class.java, editor) != null) return false + val decl = findElement(file, document, Function::class.java, editor) + val target = when { + decl != null -> decl + else -> findElementAtFunctionCall(file, document, editor) + } + if (target == null) return false + if (getInsertionLine(document, target) == line) return false + return !isSuppressionAlreadyPresent(target) + } + + override fun invoke(project: Project, editor: Editor, file: PsiFile?) { + if (file == null) return + val decl = findElement(file, editor.document, Function::class.java, editor) + val target = when { + decl != null && decl !is Method -> decl + else -> findElementAtFunctionCall(file, editor.document, editor) + } + if (target == null) return + if (IntentionPreviewUtils.isIntentionPreviewActive()) { + applyInsertToElement(editor, target) + } else { + WriteCommandAction.runWriteCommandAction(project) { + insertIgnoreAtElement(project, editor, file, target) + } + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoIgnoreAction.kt b/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoIgnoreAction.kt new file mode 100644 index 0000000..840670f --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoIgnoreAction.kt @@ -0,0 +1,429 @@ +package com.github.xepozz.mago.intentions.suppress + +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer +import com.intellij.codeInsight.intention.FileModifier +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.codeInsight.intention.PriorityAction +import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo +import com.intellij.codeInsight.intention.preview.IntentionPreviewUtils +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.codeStyle.CodeStyleManager +import com.intellij.psi.PsiWhiteSpace +import com.intellij.psi.util.PsiTreeUtil +import com.jetbrains.php.lang.documentation.phpdoc.PhpDocUtil +import com.jetbrains.php.lang.documentation.phpdoc.psi.PhpDocComment +import com.jetbrains.php.lang.documentation.phpdoc.psi.tags.PhpDocTag +import com.jetbrains.php.lang.psi.elements.Function +import com.jetbrains.php.lang.psi.elements.FunctionReference +import com.jetbrains.php.lang.psi.elements.Method +import com.jetbrains.php.lang.psi.elements.MethodReference +import com.jetbrains.php.lang.psi.elements.PhpClass +import com.jetbrains.php.lang.psi.elements.Statement +import com.github.xepozz.mago.MagoBundle +import com.jetbrains.php.lang.psi.elements.PhpNamedElement +import com.jetbrains.php.lang.psi.PhpPsiElementFactory + +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, + /** When true, insert @mago-expect instead of @mago-ignore. */ + protected open val useExpect: Boolean = false +) : IntentionAction, PriorityAction, FileModifier { + override fun getFamilyName() = MagoBundle.message("intention.familyName") + + /** Only primitives and Strings; safe to apply to a copy for intention preview. */ + override fun getFileModifierForPreview(target: PsiFile): FileModifier = this + override fun startInWriteAction() = true + + /** + * Explicit override so submenu options get a diff preview when hovered. + * The platform may not use the default path for options; + * applying `invoke()` on the supplied file (copy) and returning DIFF ensures preview works. + */ + override fun generatePreview(project: Project, editor: Editor, file: PsiFile): IntentionPreviewInfo { + invoke(project, editor, file) + return IntentionPreviewInfo.DIFF + } + + override fun isAvailable(project: Project, editor: Editor, file: PsiFile): Boolean { + if (file.project != project) return false + val document = editor.document + val offset = getElementOffset(document, editor) + if (offset >= document.textLength) return false + val elementAt = file.findElementAt(offset) ?: return false + + // If the user already added the suppression right above the current statement/declaration, + // hide all suppress actions immediately (even before the analyzer re-runs). + if (isSuppressionAlreadyPresent(elementAt)) return false + + // Also hide if an enclosing function/method/class is suppressed (directive above its declaration line). + // This covers: apply "for function" → move to another statement in the same function before re-analysis. + if (isSuppressedInEnclosingDeclarations(elementAt)) return false + + return true + } + + /** Quick fixes first (HIGH), then navigate (NORMAL), then suppress (LOW). */ + override fun getPriority() = PriorityAction.Priority.LOW + + /** Shared title for scope-specific actions: "Mago: Ignore/Expect `category:code` for ". */ + protected fun scopeText(scopeName: String): String = + if (useExpect) MagoBundle.message("intention.scopeText.expect", category, code, scopeName) + else MagoBundle.message("intention.scopeText.ignore", category, code, scopeName) + + /** True if the PHPDoc right before [anchor] already contains this suppression (ignore or expect). */ + protected fun isSuppressionAlreadyPresent(anchor: PsiElement): Boolean { + val doc = findPhpDocImmediatelyBefore(anchor) ?: return false + return hasSuppressionInPhpDoc(doc) + } + + private fun isSuppressedInEnclosingDeclarations(elementAt: PsiElement): Boolean { + fun isDeclSuppressed(decl: PhpNamedElement): Boolean { + val doc = decl.docComment ?: return false + return hasSuppressionInPhpDoc(doc) + } + + PsiTreeUtil.getParentOfType(elementAt, Method::class.java)?.let { if (isDeclSuppressed(it)) return true } + PsiTreeUtil.getParentOfType(elementAt, Function::class.java)?.let { if (isDeclSuppressed(it)) return true } + PsiTreeUtil.getParentOfType(elementAt, PhpClass::class.java)?.let { if (isDeclSuppressed(it)) return true } + return false + } + + private fun findPhpDocImmediatelyBefore(anchor: PsiElement): PhpDocComment? { + var prev = anchor.prevSibling + while (prev is PsiWhiteSpace) { + prev = prev.prevSibling + } + return prev as? PhpDocComment + } + + /** Tag value is only the first line; the following lines are description, not part of the tag. */ + private fun getTagValueFirstLine(tag: PhpDocTag): String = + PhpDocUtil.getTagValue(tag, true) + .trim() + .substringBefore('\n') + .trim() + + private fun hasSuppressionInPhpDoc(doc: PhpDocComment): Boolean { + fun codesFrom(tag: PhpDocTag): Set { + val value = getTagValueFirstLine(tag) + val cat = value.substringBefore(':', missingDelimiterValue = "") + if (cat != category) return emptySet() + val codesPart = value.substringAfter(':', missingDelimiterValue = "") + if (codesPart.isEmpty()) return emptySet() + return codesPart.split(',').map { it.trim() }.filter { it.isNotEmpty() }.toSet() + } + + val ignoreTags = doc.getTagElementsByName("@mago-ignore").asList() + val expectTags = doc.getTagElementsByName("@mago-expect").asList() + return ignoreTags.any { code in codesFrom(it) } || expectTags.any { code in codesFrom(it) } + } + + /** + * When we're at a function or method call (e.g. `substr(...)` or `$o->foo(...)`), + * returns the call element (FunctionReference or MethodReference) so "Suppress for function" + * can insert above that line. Uses PHP plugin PSI so only real calls are detected, not + * keywords like `catch`. Handles offset landing on the call name or on an argument. + */ + protected fun findElementAtFunctionCall(file: PsiFile, document: Document, editor: Editor? = null): PsiElement? { + val offset = getElementOffset(document, editor) + if (offset >= document.textLength) return null + var elementAt = file.findElementAt(offset) ?: return null + var currentOffset = offset + while (elementAt is PsiWhiteSpace && currentOffset < document.textLength - 1) { + currentOffset++ + val nextElement = file.findElementAt(currentOffset) ?: break + if (nextElement != elementAt) { + elementAt = nextElement + if (elementAt !is PsiWhiteSpace) break + } + } + val call = PsiTreeUtil.getParentOfType(elementAt, FunctionReference::class.java) + ?: PsiTreeUtil.getParentOfType(elementAt, MethodReference::class.java) + if (call != null) return call + for (delta in 1..50) { + val back = (offset - delta).coerceAtLeast(0) + if (delta > 1 && document.getLineNumber(back) != document.getLineNumber(offset)) break + val el = file.findElementAt(back) ?: continue + if (el is PsiWhiteSpace) continue + val callBack = PsiTreeUtil.getParentOfType(el, FunctionReference::class.java) + ?: PsiTreeUtil.getParentOfType(el, MethodReference::class.java) + if (callBack != null) return callBack + } + return null + } + + protected fun findElement( + file: PsiFile, + document: Document, + clazz: Class, + editor: Editor? = null + ): T? { + val offset = getElementOffset(document, editor) + if (offset >= document.textLength) return null + + var elementAt = file.findElementAt(offset) + + var currentOffset = offset + while (elementAt is PsiWhiteSpace && currentOffset < document.textLength - 1) { + currentOffset++ + val nextElement = file.findElementAt(currentOffset) + if (nextElement != null && nextElement != elementAt) { + elementAt = nextElement + if (elementAt !is PsiWhiteSpace) break + } + } + + // When looking for Function/Method, the offset may land on the first argument; + // scan backward to find the function name + if (elementAt != null && (clazz == Function::class.java || clazz == Method::class.java)) { + for (delta in 0..50) { + val back = (offset - delta).coerceAtLeast(0) + if (delta > 0 && document.getLineNumber(back) != document.getLineNumber(offset)) break + val el = file.findElementAt(back) ?: continue + if (el is PsiWhiteSpace) continue + val parent = PsiTreeUtil.getParentOfType(el, clazz) + if (parent != null) { + @Suppress("UNCHECKED_CAST") + return parent as T + } + } + } + + @Suppress("UNCHECKED_CAST") + val parent = if (clazz == PsiElement::class.java) { + elementAt as T? + } else { + PsiTreeUtil.getParentOfType(elementAt, clazz) + } + return parent + } + + /** Document-only insert (used for intention preview; no write action, no commit/restart). */ + protected fun applyInsertToElement(editor: Editor, element: PsiElement) { + // Pass editor.document so preview works when the preview file isn't in PsiDocumentManager. + val edit = computeSuppressEdit(element.project, element, editor.document) ?: return + applySuppressEdit(editor.document, edit) + } + + protected fun insertIgnoreAtElement( + project: Project, + editor: Editor, + file: PsiFile, + element: PsiElement + ) { + if (IntentionPreviewUtils.isIntentionPreviewActive()) { + applyInsertToElement(editor, element) + return + } + + WriteCommandAction.runWriteCommandAction(project) { + val edit = computeSuppressEdit(project, element) ?: return@runWriteCommandAction + val document = PsiDocumentManager.getInstance(project).getDocument(file) ?: return@runWriteCommandAction + applySuppressEdit(document, edit) + val psiDocumentManager = PsiDocumentManager.getInstance(project) + psiDocumentManager.commitDocument(document) + val endOffset = edit.startOffset + edit.newText.length + CodeStyleManager.getInstance(project).reformatText(file, edit.startOffset, endOffset) + } + finalizeChange(project, editor, file) + } + + protected fun invokeForElement( + project: Project, + editor: Editor, + file: PsiFile, + clazz: Class, + predicate: (T) -> Boolean = { true } + ) { + val element = findElement(file, editor.document, clazz, editor) ?: return + if (!predicate(element)) return + if (IntentionPreviewUtils.isIntentionPreviewActive()) { + applyInsertToElement(editor, element) + } else { + WriteCommandAction.runWriteCommandAction(project) { + insertIgnoreAtElement(project, editor, file, element) + } + } + } + + protected fun finalizeChange(project: Project, editor: Editor, file: PsiFile) { + val document = editor.document + + val psiDocumentManager = PsiDocumentManager.getInstance(project) + psiDocumentManager.commitDocument(document) + psiDocumentManager.doPostponedOperationsAndUnblockDocument(document) + + DaemonCodeAnalyzer.getInstance(project).restart(file) + } + + /** Mago reports 1-based line numbers; Document uses 0-based line index. */ + protected fun lineToIndex(document: Document): Int = + (line - 1).coerceIn(0, document.lineCount - 1) + + /** 1-based line number where a comment would be inserted for the given element (same as "for statement" uses). */ + protected fun getInsertionLine(document: Document, element: PsiElement): Int = + document.getLineNumber(element.textRange.startOffset) + 1 + + /** + * Offset at which to find the element. + * When [editor] is provided, uses the caret offset so insert/merge use the current document + * position (fixes grouping when the file changed after the annotation was created, e.g.: during re-analysis). + */ + protected fun getElementOffset(document: Document, editor: Editor? = null): Int { + if (editor != null) { + return editor.caretModel.offset.coerceIn(0, (document.textLength - 1).coerceAtLeast(0)) + } + return problemStartOffset?.coerceIn(0, (document.textLength - 1).coerceAtLeast(0)) + ?: document.getLineStartOffset(lineToIndex(document)) + } + + /** Single document edit used for both preview and apply so they always match. */ + private data class SuppressEdit(val startOffset: Int, val endOffset: Int, val newText: String) + + /** + * @param documentFallback used when the file has no document in PsiDocumentManager (e.g.: intention preview copy). + */ + private fun computeSuppressEdit( + project: Project, + element: PsiElement, + documentFallback: Document? = null + ): SuppressEdit? { + val file = element.containingFile ?: return null + val document = documentFallback ?: PsiDocumentManager.getInstance(project).getDocument(file) ?: return null + val tagName = if (useExpect) "@mago-expect" else "@mago-ignore" + val anchor = insertionAnchor(element) + val doc = findPhpDocImmediatelyBefore(anchor) + + if (doc != null) { + val existing = findMagoTagForCategory(doc, tagName, category) + val newText = if (existing != null) { + val merged = mergeCodesInTag(existing, tagName, category, code) ?: return null + val existingFirstLineEnd = existing.text.indexOf('\n').takeIf { it >= 0 } ?: existing.text.length + val mergedLine = merged.text.substringBefore('\n').ifEmpty { merged.text } + val tagStartInDoc = existing.textRange.startOffset - doc.textRange.startOffset + doc.text.replaceRange(tagStartInDoc, tagStartInDoc + existingFirstLineEnd, mergedLine) + } else { + val addBlankLine = PhpDocUtil.getDescription(doc, true).isNotBlank() && + doc.getTagElementsByName("@mago-ignore").isEmpty() && + doc.getTagElementsByName("@mago-expect").isEmpty() + + insertMagoTagIntoPhpDocText( + doc.text, + tagText = "$tagName $category:$code", + addBlankLineBefore = addBlankLine + ) ?: return null + } + return SuppressEdit(doc.textRange.startOffset, doc.textRange.endOffset, newText) + } + + val lineStart = document.getLineStartOffset(document.getLineNumber(anchor.textRange.startOffset)) + return SuppressEdit( + startOffset = lineStart, + endOffset = lineStart, + newText = "/** $tagName $category:$code */\n" + ) + } + + private fun applySuppressEdit(document: Document, edit: SuppressEdit) { + if (edit.startOffset == edit.endOffset) { + document.insertString(edit.startOffset, edit.newText) + } else { + document.replaceString(edit.startOffset, edit.endOffset, edit.newText) + } + } + + private fun insertionAnchor(element: PsiElement): PsiElement { + return when (element) { + is Method, is Function, is PhpClass -> element + else -> PsiTreeUtil.getParentOfType(element, Statement::class.java) ?: element + } + } + + private fun findMagoTagForCategory(doc: PhpDocComment, tagName: String, category: String): PhpDocTag? { + val tags = doc.getTagElementsByName(tagName) + for (t in tags) { + val value = getTagValueFirstLine(t) + val cat = value.substringBefore(':', missingDelimiterValue = "") + if (cat == category) return t + } + return null + } + + private fun mergeCodesInTag(existing: PhpDocTag, tagName: String, category: String, code: String): PhpDocTag? { + val value = getTagValueFirstLine(existing) + val cat = value.substringBefore(':', missingDelimiterValue = "") + if (cat != category) return null + val codesPart = value.substringAfter(':', missingDelimiterValue = "") + val codes = codesPart.split(',').map { it.trim() }.filter { it.isNotEmpty() }.toMutableSet() + if (!codes.add(code)) return null + val mergedText = "$tagName $category:${codes.sorted().joinToString(",")}" + return createDocTag(project = existing.project, tagText = mergedText) + } + + private fun normalizeSingleLinePhpDocToMultiline(docText: String): String { + if (docText.contains('\n')) return docText + val start = docText.indexOf("/**") + val end = docText.lastIndexOf("*/") + if (start < 0 || end < 0 || end <= start + 3) return docText + val inner = docText.substring(start + 3, end).trim() + val linePrefix = " " // formatter will apply project indent + return if (inner.isEmpty()) { + "/**\n$linePrefix*/" + } else { + "/**\n$linePrefix* $inner\n$linePrefix*/" + } + } + + private fun insertMagoTagIntoPhpDocText( + docText: String, + tagText: String, + addBlankLineBefore: Boolean + ): String? { + val normalized = normalizeSingleLinePhpDocToMultiline(docText) + val end = normalized.lastIndexOf("*/") + if (end < 0) return null + val insertionPoint = normalized.lastIndexOf('\n', end).takeIf { it >= 0 } ?: end + val linePrefix = " " // formatter will apply project indent + val sb = StringBuilder() + if (addBlankLineBefore) { + sb.append("\n").append(linePrefix).append("*") + } + sb.append("\n").append(linePrefix).append("* ").append(tagText) + return normalized.substring(0, insertionPoint) + sb.toString() + normalized.substring(insertionPoint) + } + + private fun createDocTag(project: Project, tagText: String): PhpDocTag { + // Match JetBrains' own suppression fix template to get a correctly parsed PhpDocTag. + val template = "/**\n* $tagText*/\nfunction a() {}" + return PhpPsiElementFactory.createPhpPsiFromText(project, PhpDocTag::class.java, template) + } + + /** + * True if the problem offset is on the same line as a containing Method, Function, or PhpClass + * declaration (so "for statement" should not be shown). + */ + protected fun isOnDeclarationLine(file: PsiFile, document: Document, editor: Editor? = null): Boolean { + val offset = getElementOffset(document, editor) + if (offset >= document.textLength) return false + val elementAt = file.findElementAt(offset) ?: return false + val currentLine = document.getLineNumber(offset) + for (clazz in listOf(Method::class.java, Function::class.java, PhpClass::class.java)) { + val container = PsiTreeUtil.getParentOfType(elementAt, clazz) + if (container != null && document.getLineNumber(container.textRange.startOffset) == currentLine) { + return true + } + } + + return false + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoIgnoreFirstAvailableAction.kt b/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoIgnoreFirstAvailableAction.kt new file mode 100644 index 0000000..4ea7bfe --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoIgnoreFirstAvailableAction.kt @@ -0,0 +1,40 @@ +package com.github.xepozz.mago.intentions.suppress + +import com.github.xepozz.mago.MagoBundle +import com.intellij.codeInsight.intention.FileModifier +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile + +/** + * Invokes the first available action from the list when the user chooses the main "Ignore" entry. + * Implements FileModifier so the parent submenu can provide an intention preview (hover / Ctrl+Q). + */ +internal class MagoIgnoreFirstAvailableAction( + private val actions: List, +) : IntentionAction, FileModifier { + override fun getFamilyName() = MagoBundle.message("intention.familyName") + override fun getText() = + (actions.firstOrNull() as? MagoIgnoreAction)?.let { + MagoBundle.message("intention.ignore.withCode", it.category, it.code) + } ?: MagoBundle.message("intention.ignore.generic") + + override fun startInWriteAction(): Boolean = actions.firstOrNull()?.startInWriteAction() ?: true + override fun isAvailable(project: Project, editor: Editor, file: PsiFile): Boolean = + actions.any { it.isAvailable(project, editor, file) } + + override fun invoke(project: Project, editor: Editor, file: PsiFile) { + actions.firstOrNull { it.isAvailable(project, editor, file) }?.invoke(project, editor, file) + } + + override fun getElementToMakeWritable(currentFile: PsiFile): PsiElement = + (actions.firstOrNull() as? FileModifier)?.getElementToMakeWritable(currentFile) ?: currentFile + + override fun getFileModifierForPreview(target: PsiFile): FileModifier? { + val copies = actions.mapNotNull { (it as? FileModifier)?.getFileModifierForPreview(target) as? IntentionAction } + if (copies.size != actions.size) return null + return MagoIgnoreFirstAvailableAction(copies) + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoIgnoreSubmenuAction.kt b/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoIgnoreSubmenuAction.kt new file mode 100644 index 0000000..9041538 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoIgnoreSubmenuAction.kt @@ -0,0 +1,77 @@ +package com.github.xepozz.mago.intentions.suppress + +import com.github.xepozz.mago.MagoBundle +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.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile + +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() = MagoBundle.message("intention.familyName") + override fun getText() = MagoBundle.message("intention.suppress.withCode", category, code) + override fun invoke(project: Project, editor: Editor, file: PsiFile) { + mainAction.invoke(project, editor, file) + } + + override fun getOptions(): List = actions + + override fun getCombiningPolicy(): IntentionActionWithOptions.CombiningPolicy { + return IntentionActionWithOptions.CombiningPolicy.IntentionOptionsOnly + } + + override fun getPriority() = PriorityAction.Priority.LOW + override fun isAvailable(project: Project, editor: Editor, file: PsiFile): Boolean { + return actions.any { it.isAvailable(project, editor, file) } + } + + override fun startInWriteAction() = mainAction.startInWriteAction() +} + +/** + * Builds the 8 context-specific suppress actions in display order + * (Ignore then Expect; statement, function, method, class). + * + * @todo Order kinda doesn't really work, doesn't matter that much + */ +fun createSuppressActions(category: String, code: String, line: Int, problemStartOffset: Int?): List = + listOf( + MagoStatementSuppressAction(category, code, line, problemStartOffset, useExpect = false), + MagoFunctionSuppressAction(category, code, line, problemStartOffset, useExpect = false), + MagoMethodSuppressAction(category, code, line, problemStartOffset, useExpect = false), + MagoClassSuppressAction(category, code, line, problemStartOffset, useExpect = false), + MagoStatementSuppressAction(category, code, line, problemStartOffset, useExpect = true), + MagoFunctionSuppressAction(category, code, line, problemStartOffset, useExpect = true), + MagoMethodSuppressAction(category, code, line, problemStartOffset, useExpect = true), + MagoClassSuppressAction(category, code, line, problemStartOffset, useExpect = true), + ) + +/** + * Creates a single "Mago: Suppress `category:code`" fix with a submenu of context-specific options + * in order: Ignore/Expect for statement, function, method, class (each only shown when applicable). + */ +fun createSuppressSubmenuAction(category: String, code: String, line: Int, problemStartOffset: Int?): IntentionAction { + val actions = createSuppressActions(category, code, line, problemStartOffset) + return MagoIgnoreSubmenuAction(category, code, MagoIgnoreFirstAvailableAction(actions), actions) +} diff --git a/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoMethodSuppressAction.kt b/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoMethodSuppressAction.kt new file mode 100644 index 0000000..0ba2753 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoMethodSuppressAction.kt @@ -0,0 +1,30 @@ +package com.github.xepozz.mago.intentions.suppress + +import com.github.xepozz.mago.MagoBundle +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiFile +import com.jetbrains.php.lang.psi.elements.Method + +class MagoMethodSuppressAction( + category: String, + code: String, + line: Int, + problemStartOffset: Int? = null, + useExpect: Boolean = false +) : MagoIgnoreAction(category, code, line, problemStartOffset, useExpect) { + + override fun getText() = scopeText(MagoBundle.message("intention.scope.method")) + + override fun isAvailable(project: Project, editor: Editor, file: PsiFile): Boolean { + if (!super.isAvailable(project, editor, file)) return false + val method = findElement(file, editor.document, Method::class.java, editor) ?: return false + if (getInsertionLine(editor.document, method) == line) return false + return !isSuppressionAlreadyPresent(method) + } + + override fun invoke(project: Project, editor: Editor, file: PsiFile?) { + if (file == null) return + invokeForElement(project, editor, file, Method::class.java) + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoStatementSuppressAction.kt b/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoStatementSuppressAction.kt new file mode 100644 index 0000000..6825269 --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/intentions/suppress/MagoStatementSuppressAction.kt @@ -0,0 +1,39 @@ +package com.github.xepozz.mago.intentions.suppress + +import com.github.xepozz.mago.MagoBundle +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.psi.PsiElement +import com.intellij.psi.PsiFile + +class MagoStatementSuppressAction( + category: String, + code: String, + line: Int, + problemStartOffset: Int? = null, + useExpect: Boolean = false +) : MagoIgnoreAction(category, code, line, problemStartOffset, useExpect) { + + override fun getText() = scopeText(MagoBundle.message("intention.scope.statement")) + + override fun isAvailable(project: Project, editor: Editor, file: PsiFile): Boolean { + if (!super.isAvailable(project, editor, file)) return false + if (isOnDeclarationLine(file, editor.document, editor)) return false + val element = findElement(file, editor.document, PsiElement::class.java, editor) ?: return true + return !isSuppressionAlreadyPresent(element) + } + + override fun invoke(project: Project, editor: Editor, file: PsiFile?) { + if (file == null) return + val element = findElement(file, editor.document, PsiElement::class.java, editor) ?: return + if (IntentionPreviewUtils.isIntentionPreviewActive()) { + applyInsertToElement(editor, element) + } else { + WriteCommandAction.runWriteCommandAction(project) { + insertIgnoreAtElement(project, editor, file, element) + } + } + } +} diff --git a/src/main/kotlin/com/github/xepozz/mago/mixin.kt b/src/main/kotlin/com/github/xepozz/mago/mixin.kt index ea28112..ab654eb 100644 --- a/src/main/kotlin/com/github/xepozz/mago/mixin.kt +++ b/src/main/kotlin/com/github/xepozz/mago/mixin.kt @@ -2,6 +2,8 @@ package com.github.xepozz.mago import com.intellij.openapi.util.TextRange import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile import org.toml.lang.psi.TomlLiteral val TomlLiteral.contents @@ -10,6 +12,35 @@ val TomlLiteral.contents val TomlLiteral.contentRange get() = TextRange(1, textLength - 1) -fun String.normalizePath() = - removePrefix("\\\\?\\") - .let { FileUtil.toSystemIndependentName(it) } \ No newline at end of file +/** + * Normalizes a path for comparison: long path prefix, system-independent slashes, + * and WSL UNC variants. Both `//wsl$/` and `//wsl.localhost/` refer to the same + * WSL filesystem; canonicalize to one form so file-vs-config and workspace mapping + * comparisons work regardless of which form the IDE or user supplies. + */ +fun String.normalizePath(): String { + val withSlashes = removePrefix("\\\\?\\").let { FileUtil.toSystemIndependentName(it) } + return if (withSlashes.startsWith("//wsl$/")) { + "//wsl.localhost/" + withSlashes.removePrefix("//wsl$/") + } else { + withSlashes + } +} + +/** + * Converts a path to the form used when passing to Mago (or the PHP path mapper). + * WSL paths in `//wsl.localhost/` form are rewritten to `//wsl$/` so they are + * found correctly; the `//wsl$/` form is what works for both local Mago and + * remote path mapping on Windows. + */ +fun String.toPathForExecution(): String { + val withSlashes = removePrefix("\\\\?\\").let { FileUtil.toSystemIndependentName(it) } + return if (withSlashes.startsWith("//wsl.localhost/")) { + "//wsl$/" + withSlashes.removePrefix("//wsl.localhost/") + } else { + withSlashes + } +} + +fun String.findVirtualFile(): VirtualFile? = + LocalFileSystem.getInstance().findFileByPath(this) diff --git a/src/main/kotlin/com/github/xepozz/mago/model/MagoProblemDescription.kt b/src/main/kotlin/com/github/xepozz/mago/model/MagoProblemDescription.kt new file mode 100644 index 0000000..56143ce --- /dev/null +++ b/src/main/kotlin/com/github/xepozz/mago/model/MagoProblemDescription.kt @@ -0,0 +1,44 @@ +package com.github.xepozz.mago.model + +enum class MagoSeverity { + ERROR, + WARNING, + INFO +} + +data class MagoAnnotationSpan( + val message: String, + val kind: String, + val filePath: String, + val startOffset: Int, + val endOffset: Int, + val line: Int, +) + +data class MagoProblemDescription( + val severity: MagoSeverity, + val lineNumber: Int, + val startChar: Int, + val endChar: Int, + var myMessage: String, + val myFile: String, + val code: String, + val category: String, + val help: String, + val notes: List, + val edits: List = emptyList(), + val secondaryAnnotations: List = emptyList(), +) + +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/MagoAddToIgnoredAction.kt b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoAddToIgnoredAction.kt deleted file mode 100644 index 571bea9..0000000 --- a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoAddToIgnoredAction.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.github.xepozz.mago.qualityTool - -import com.github.xepozz.mago.configuration.MagoConfiguration -import com.intellij.openapi.project.Project -import com.jetbrains.php.tools.quality.QualityToolAddToIgnoredAction -import com.jetbrains.php.tools.quality.QualityToolType - -class MagoAddToIgnoredAction : QualityToolAddToIgnoredAction() { - override fun getQualityToolType(project: Project?): QualityToolType { - return MagoQualityToolType.INSTANCE - } -} 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..4419ae6 100644 --- a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoAnnotatorProxy.kt +++ b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoAnnotatorProxy.kt @@ -1,120 +1,44 @@ package com.github.xepozz.mago.qualityTool -import com.github.xepozz.mago.configuration.MagoProjectConfiguration -import com.github.xepozz.mago.normalizePath -import com.github.xepozz.mago.utils.DebugLogger +import com.intellij.codeHighlighting.HighlightDisplayLevel import com.intellij.codeInspection.InspectionProfile -import com.intellij.execution.configurations.ParametersList -import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.module.ModuleUtilCore import com.intellij.openapi.project.Project -import com.intellij.openapi.project.rootManager -import com.intellij.openapi.util.io.FileUtil -import com.intellij.openapi.vfs.LocalFileSystem -import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiFile import com.jetbrains.php.tools.quality.QualityToolAnnotator import com.jetbrains.php.tools.quality.QualityToolAnnotatorInfo import com.jetbrains.php.tools.quality.QualityToolConfiguration +import com.jetbrains.php.tools.quality.QualityToolMessage +import com.jetbrains.php.tools.quality.QualityToolMessageProcessor +/** + * Minimal stub required by the QualityTool infrastructure. + * The actual annotation pipeline uses [com.github.xepozz.mago.annotator.MagoExternalAnnotator]. + */ open class MagoAnnotatorProxy : QualityToolAnnotator() { companion object { - private val LOG: Logger = Logger.getInstance(MagoAnnotatorProxy::class.java) - val INSTANCE = MagoAnnotatorProxy() + } - fun getFormatOptions(settings: MagoProjectConfiguration, project: Project, files: Collection) = - buildList { - val workspace = findWorkspace(project, files.firstOrNull()) - addWorkspace(workspace, project) - addConfig(workspace, project, settings) - - add("fmt") - addAll(files.map { toWorkspaceRelativePath(workspace, it) }) - } - .plus(ParametersList.parse(settings.formatAdditionalParameters)) - .apply { - DebugLogger.inform( - project, - "Format options", - """Options: ${joinToString(" ")}, filePaths: ${files.joinToString(", ") { it }}""", - ) - - } - - fun findWorkspace(project: Project, filePath: String?): VirtualFile { - if (filePath == null) return project.baseDir - val file = LocalFileSystem.getInstance().findFileByPath(filePath) ?: return project.baseDir - val module = ModuleUtilCore.findModuleForFile(file, project) ?: return project.baseDir - return module.rootManager.contentRoots.firstOrNull() ?: project.baseDir - } - - fun getAnalyzeOptions(settings: MagoProjectConfiguration, project: Project, filePath: String) = buildList { - val workspace = findWorkspace(project, filePath) - addWorkspace(workspace, project) - addConfig(workspace, project, settings) - - add("analyze") - add(toWorkspaceRelativePath(workspace, filePath)) - add("--reporting-format=json") -// filePath?.let { add(it) } - } - .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) - } - - internal fun toRelativePath(basePath: String, absoluteFilePath: String): String { - val basePath = basePath.normalizePath() - val absoluteFilePath = absoluteFilePath.normalizePath() - - val relative = FileUtil.getRelativePath(basePath, absoluteFilePath, '/') - return ensureMagoPath(relative ?: absoluteFilePath) - } - - internal fun ensureMagoPath(path: String): String = when { - path.isEmpty() -> path - FileUtil.isAbsolute(path) || isWindowsAbsolute(path) || path.startsWith('\\') -> path - path.startsWith("./") || path.startsWith(".\\") -> path - // Mago ignores relative paths unless they are explicitly prefixed. - else -> "./$path" - } + override fun getQualityToolType() = MagoQualityToolType.INSTANCE - private fun isWindowsAbsolute(path: String): Boolean = path.length >= 2 && path[0].isLetter() && path[1] == ':' + override fun createMessageProcessor(collectedInfo: QualityToolAnnotatorInfo) = + object : QualityToolMessageProcessor(collectedInfo) { + override fun parseLine(line: String) {} + override fun severityToDisplayLevel(severity: QualityToolMessage.Severity) = + HighlightDisplayLevel.WEAK_WARNING - private fun MutableList.addWorkspace(workspace: VirtualFile, project: Project) { - val projectPath = updateIfRemoteMappingExists( - workspace.path, - project, - INSTANCE.qualityToolType - ).let { FileUtil.toSystemIndependentName(it) } - add("--workspace=$projectPath") + override fun getQualityToolType() = MagoQualityToolType.INSTANCE + override fun done() {} } - private fun MutableList.addConfig( - workspace: VirtualFile, - project: Project, - settings: MagoProjectConfiguration - ) { - val configurationFile = updateIfRemoteMappingExists( - settings.configurationFile, - project, - INSTANCE.qualityToolType, - ) + override fun getPairedBatchInspectionShortName() = qualityToolType.inspectionId - if (configurationFile.isNotEmpty()) { - add("--config=$configurationFile") - } - } - } + override fun getOptions( + filePath: String?, + inspection: MagoValidationInspection, + profile: InspectionProfile?, + project: Project, + ): List = emptyList() override fun createAnnotatorInfo( file: PsiFile?, @@ -127,28 +51,5 @@ open class MagoAnnotatorProxy : QualityToolAnnotator() return super.createAnnotatorInfo(file, tool, inspectionProfile, project, configuration, false) } - override fun getOptions( - filePath: String?, - inspection: MagoValidationInspection, - profile: InspectionProfile?, - project: Project, - ): List { - checkNotNull(filePath) - val settings = project.getService(MagoProjectConfiguration::class.java) - - return getAnalyzeOptions(settings, project, filePath) - } - - override fun getQualityToolType() = MagoQualityToolType.INSTANCE - - override fun createMessageProcessor(collectedInfo: QualityToolAnnotatorInfo) = - MagoMessageProcessor(collectedInfo) - - override fun getPairedBatchInspectionShortName() = qualityToolType.inspectionId - - /** - * It seems it may break work with Docker, - * but in another case, errors are mapped over wrong code tokes - */ override fun runOnTempFiles() = true } 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/MagoJsonMessageHandler.kt b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoJsonMessageHandler.kt deleted file mode 100644 index 0223721..0000000 --- a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoJsonMessageHandler.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.github.xepozz.mago.qualityTool - -import com.google.gson.JsonParser -import com.intellij.openapi.util.io.FileUtil -import com.jetbrains.php.tools.quality.QualityToolMessage - -class MagoJsonMessageHandler { - fun parseJson(line: String): List { -// println("JSON: $line") - return JsonParser.parseString(line) - .apply { if (this == null || this.isJsonNull) return emptyList() } - .asJsonObject - .getAsJsonArray("issues") - ?.map { it.asJsonObject } - ?.flatMap { issue -> - issue.getAsJsonArray("annotations") - ?.map { it.asJsonObject } - ?.filter { it.get("kind").asString == "Primary" } - ?.mapNotNull { annotation -> - val span = annotation.getAsJsonObject("span") ?: return@mapNotNull null - val filePath = (span.getAsJsonObject("file_id") - ?.get("path") - ?.asString - ?.removePrefix("\\\\?\\") - ?.let { FileUtil.toSystemIndependentName(it) } - ?: return@mapNotNull null) - - 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}]", - filePath, - issue.get("code")?.asString ?: "", - issue.get("help")?.asString ?: "", - issue.getAsJsonArray("notes")?.map { it.asString } ?: emptyList(), - ) - } - ?: emptyList() - } - ?: emptyList() - } - - fun levelToSeverity(level: String?) = when (level) { - "Error" -> QualityToolMessage.Severity.ERROR - "Warning" -> QualityToolMessage.Severity.WARNING - else -> null - } -} \ 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 deleted file mode 100644 index 8bb7393..0000000 --- a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoMessageProcessor.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.github.xepozz.mago.qualityTool - -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 -import com.jetbrains.php.tools.quality.QualityToolMessageProcessor - -// it does analysis everytime when you change a file in the file editor -// should be optimized -class MagoMessageProcessor(private val info: QualityToolAnnotatorInfo<*>) : QualityToolMessageProcessor(info) { - var startParsing = false - val buffer = StringBuffer() - val errorBuffer = StringBuffer() - - override fun getQualityToolType() = MagoQualityToolType.INSTANCE - - override fun parseLine(line: String) { - val outputLine = line.trim() - -// println("parseLine $outputLine for $info") - if (!startParsing) { - if (!outputLine.startsWith("{")) { - errorBuffer.append(outputLine) - return - } - startParsing = true - } - - buffer.append(outputLine) - } - - 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), - ) - } - .apply { - if (isEmpty() && errorBuffer.isNotEmpty()) { - throw QualityToolExecutionException("Caught errors while running Mago: $errorBuffer") - } - } - .forEach { addMessage(it) } - } - - 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/MagoProblemDescription.kt b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoProblemDescription.kt deleted file mode 100644 index 0cb94bf..0000000 --- a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoProblemDescription.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.github.xepozz.mago.qualityTool - -import com.jetbrains.php.tools.quality.QualityToolMessage -import com.jetbrains.php.tools.quality.QualityToolXmlMessageProcessor - -class MagoProblemDescription( - severity: QualityToolMessage.Severity?, - lineNumber: Int, - val startChar: Int, - val endChar: Int, - var myMessage: String, - val myFile: String, - val code: String, - val help: String, - val notes: List, -) : QualityToolXmlMessageProcessor.ProblemDescription( - severity, - lineNumber, - startChar, - myMessage, - myFile, -) diff --git a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoQualityToolType.kt b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoQualityToolType.kt index 9850ddd..849989c 100644 --- a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoQualityToolType.kt +++ b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoQualityToolType.kt @@ -1,5 +1,6 @@ package com.github.xepozz.mago.qualityTool +import com.github.xepozz.mago.MagoBundle import com.github.xepozz.mago.configuration.MagoConfigurable import com.github.xepozz.mago.configuration.MagoConfigurableForm import com.github.xepozz.mago.configuration.MagoConfiguration @@ -16,7 +17,7 @@ import com.jetbrains.php.tools.quality.QualityToolType import com.jetbrains.php.tools.quality.QualityToolValidationGlobalInspection class MagoQualityToolType : QualityToolType() { - override fun getDisplayName() = MAGO + override fun getDisplayName() = MagoBundle.message("quality.tool.mago") override fun getQualityToolBlackList(project: Project) = MagoBlackList.getInstance(project) 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/MagoValidationInspection.kt b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoValidationInspection.kt index 7dc99e5..fcfa44c 100644 --- a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoValidationInspection.kt +++ b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MagoValidationInspection.kt @@ -1,10 +1,11 @@ package com.github.xepozz.mago.qualityTool -import com.github.xepozz.mago.configuration.MagoConfigurationBaseManager.Companion.MAGO +import com.github.xepozz.mago.MagoBundle import com.jetbrains.php.tools.quality.QualityToolValidationInspection +@Suppress("InspectionDescriptionNotFoundInspection") class MagoValidationInspection : QualityToolValidationInspection() { override fun getAnnotator() = MagoAnnotatorProxy.INSTANCE - override fun getToolName() = MAGO + override fun getToolName() = MagoBundle.message("quality.tool.mago") } diff --git a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MarkIgnoreAction.kt b/src/main/kotlin/com/github/xepozz/mago/qualityTool/MarkIgnoreAction.kt deleted file mode 100644 index 5c7025d..0000000 --- a/src/main/kotlin/com/github/xepozz/mago/qualityTool/MarkIgnoreAction.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.xepozz.mago.qualityTool - -import com.intellij.codeInsight.intention.IntentionAction -import com.intellij.openapi.editor.Editor -import com.intellij.openapi.editor.LogicalPosition -import com.intellij.openapi.project.Project -import com.intellij.psi.PsiFile - -class MarkIgnoreAction(val code: String, val line: Int) : IntentionAction { - override fun getFamilyName() = "Mago" - override fun getText() = "Mark @mago-ignore `${code}`" - - 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 startInWriteAction() = true - override fun isAvailable(project: Project, editor: Editor, file: PsiFile) = true -} \ No newline at end of file diff --git a/src/main/kotlin/com/github/xepozz/mago/utils/NotificationsUtil.kt b/src/main/kotlin/com/github/xepozz/mago/utils/NotificationsUtil.kt index d221c97..c2aad26 100644 --- a/src/main/kotlin/com/github/xepozz/mago/utils/NotificationsUtil.kt +++ b/src/main/kotlin/com/github/xepozz/mago/utils/NotificationsUtil.kt @@ -6,13 +6,21 @@ import com.intellij.openapi.project.Project object NotificationsUtil { fun inform(project: Project, title: String, content: String) { + sendNotification(project, title, content, NotificationType.INFORMATION) + } + + fun error(project: Project, title: String, content: String) { + sendNotification(project, title, content, NotificationType.ERROR) + } + + private fun sendNotification(project: Project, title: String, content: String, type: NotificationType) { NotificationGroupManager.getInstance() .getNotificationGroup("Mago") .createNotification( title, content, - NotificationType.INFORMATION + type ) .notify(project) } -} \ No newline at end of file +} diff --git a/src/main/resources/META-INF/language-toml.xml b/src/main/resources/META-INF/language-toml.xml index 0b8dd4b..4904c82 100644 --- a/src/main/resources/META-INF/language-toml.xml +++ b/src/main/resources/META-INF/language-toml.xml @@ -1,13 +1,15 @@ + - diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 86be235..9101640 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -7,7 +7,7 @@ com.intellij.modules.platform com.jetbrains.php org.toml.lang - com.intellij.modules.json + com.intellij.modules.json org.jetbrains.plugins.phpstorm-remote-interpreter @@ -25,41 +25,23 @@ fileNamesCaseInsensitive="mago.toml" order="last" /> - + - - - + + implementationClass="com.github.xepozz.mago.annotator.MagoExternalAnnotator"/> @@ -70,8 +52,6 @@ - - - - - - diff --git a/src/main/resources/messages/MagoBundle.properties b/src/main/resources/messages/MagoBundle.properties index 979a43d..61be055 100644 --- a/src/main/resources/messages/MagoBundle.properties +++ b/src/main/resources/messages/MagoBundle.properties @@ -1,15 +1,128 @@ -configurable.quality.tool.php.mago=Mago -inspection.mago.display.name=Mago validation -inspection.mago.global.display.name=Mago Global validation +# Title / display name +mago.title=Mago quality.tool.mago=Mago -quality.tool.mago.quick.fix.text=Mago: fix the whole file -quality.tool.settings.link.inspection={0} inspections +# Settings - Options group +settings.options.title=Options settings.enabled=Enabled +settings.enabled.comment=Disable to stop running Mago in the background. +settings.configuration.label=Configuration +settings.defaultConfigFile.label=Default configuration file +settings.defaultConfigFile.comment=Used when no workspace mapping matches the file being analyzed. +settings.workspaceMappings.title=Workspace Mappings +settings.workspaceMappings.comment=Map workspace directories to Mago configuration files. Files are matched to the deepest matching workspace path. +settings.workspaceMappings.column.workspace=Workspace +settings.workspaceMappings.column.configFile=Configuration file + +# Settings - links +settings.link.download=Download Mago +settings.link.documentation=Documentation +settings.link.reportBug=Report bug +settings.link.requestFeature=Request feature +settings.debug.label=Debug + +# Settings - Analyzer / Formatter / Linter / Guard settings.analyzer.title=Analyzer -settings.linter.title=Linter +settings.analyzer.paramsComment=Read more: mago analyze --help settings.formatter.title=Formatter -settings.options.title=Options +settings.formatter.formatAfterFix=Format after fix +settings.formatter.formatAfterFix.comment=Add --format-after-fix when running \"Apply all suggested fixes\" (mago lint --fix). +settings.formatter.paramsComment=Read more: mago fmt --help +settings.linter.title=Linter +settings.linter.paramsComment=Read more: mago lint --help settings.guard.title=Guard +settings.guard.paramsComment=Read more: mago guard --help +settings.additionalParameters=Additional parameters + +# Remote interpreter +settings.mago.path.remote.hint=For remote interpreters, enter the path to the Mago executable on the remote system (e.g.: /usr/local/bin/mago). The path is not on your local machine. + +# Notifications +notification.group.mago=Mago + +# File type +fileType.name=Mago File +fileType.description=Mago configuration file + +# Intentions - family name +intention.familyName=Mago + +# Intentions - Ignore / Expect / Suppress +intention.ignore.generic=Mago: Ignore +intention.ignore.withCode=Mago: Ignore `{0}:{1}` +intention.expect.withCode=Mago: Expect `{0}:{1}` +intention.suppress.withCode=Mago: Suppress `{0}:{1}` +intention.scopeText.ignore=Mago: Ignore `{0}:{1}` for {2} +intention.scopeText.expect=Mago: Expect `{0}:{1}` for {2} + +# Intentions - scope names (statement, function, method, class) +intention.scope.statement=statement +intention.scope.function=function +intention.scope.method=method +intention.scope.class=class + +# Intentions - Apply fix +intention.apply.all=Mago: Apply all suggested fixes +intention.apply.one=Mago: Apply suggested fix +intention.apply.allWithScope=Mago: Apply all suggested fixes ({0}) +intention.apply.withDescription=Mago: {0} +intention.apply.suffixUnsafe= (unsafe) +intention.apply.suffixPotentiallyUnsafe= (potentially unsafe) +intention.apply.daemonRestartReason=Mago fix applied +intention.apply.suppressRestartReason=Mago suppress comment inserted + +# Intentions - Apply scope labels +apply.scope.safeOnly=fixes only safe +apply.scope.potentiallyUnsafe=potentially unsafe +apply.scope.unsafe=unsafe + +# Intentions - Remove file +intention.removeFile=Mago: Remove the file to simplify the project + +# Formatter +formatter.name=Mago +formatter.title=Mago: Formatting +formatter.error.noParentDir=Cannot determine parent directory +formatter.error.title=Mago format failed +formatter.error.exitCode=Exit code {0} + +# Validation (configurable form) +validation.versionOk=OK, Mago version {0} + +# Formatter (catch block) +formatter.error.generic=Mago format error +formatter.error.unknown=Unknown error + +# Intentions - Navigate to cause +intention.navigateToCause=Mago: Navigate to cause - {0} (line {1}) + +# Schema +schema.magoToml.name=Mago Toml + +# Annotator +annotator.failed.title=Mago failed +annotator.gutterMessage=Mago: {0} [{1}:{2}] +annotator.multipleIssues=Mago: Multiple issues found in this range +annotator.tooltip.magoLabel=Mago: +annotator.tooltip.helpLabel=Help: +annotator.tooltip.relatedLabel=Related: +annotator.tooltip.lineLabel=(line {0}) + +# Composer auto-detect +composer.detected.title=Mago detected +composer.detected.content=Found Composer-installed Mago ({0}) at: {1} +composer.action.useMago=Use this Mago +composer.action.openSettings=Open Settings + +# Problems collector (debug / notifications) +problemsCollector.exeNotConfigured=Mago executable path is not configured. +problemsCollector.skipped.title=Mago: skipped +problemsCollector.skipped.content=File: {0}
Skipped (no workspace mapping and file is not under the default config''s project). +problemsCollector.stdinFallback.title=Mago: stdin fallback +problemsCollector.stdinFallback.content=File: {0}
Mago does not support --stdin-input. Using temp file. Baselines may not match the real path - consider updating Mago. +problemsCollector.problems.title=Mago: {0} problems +problemsCollector.error.unknown=Unknown error -notification.group.mago=Mago \ No newline at end of file +# Config reference (layers) +layers.all=All layers +layers.specific=Specific layer diff --git a/src/test/kotlin/com/github/xepozz/mago/qualityTool/MagoAnnotatorProxyTest.kt b/src/test/kotlin/com/github/xepozz/mago/analysis/MagoCliOptionsTest.kt similarity index 51% rename from src/test/kotlin/com/github/xepozz/mago/qualityTool/MagoAnnotatorProxyTest.kt rename to src/test/kotlin/com/github/xepozz/mago/analysis/MagoCliOptionsTest.kt index 8c12f0f..682e370 100644 --- a/src/test/kotlin/com/github/xepozz/mago/qualityTool/MagoAnnotatorProxyTest.kt +++ b/src/test/kotlin/com/github/xepozz/mago/analysis/MagoCliOptionsTest.kt @@ -1,71 +1,70 @@ -package com.github.xepozz.mago.qualityTool +package com.github.xepozz.mago.analysis import com.intellij.openapi.util.io.FileUtil import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.io.File -class MagoAnnotatorProxyTest : BasePlatformTestCase() { +class MagoCliOptionsTest : BasePlatformTestCase() { fun `test ensureMagoPath with empty path`() { - assertEquals("", MagoAnnotatorProxy.ensureMagoPath("")) + assertEquals("", MagoCliOptions.ensureMagoPath("")) } fun `test ensureMagoPath with absolute path`() { val absolutePath = "/usr/bin/php" - assertEquals(absolutePath, MagoAnnotatorProxy.ensureMagoPath(absolutePath)) + assertEquals(absolutePath, MagoCliOptions.ensureMagoPath(absolutePath)) } fun `test ensureMagoPath with already prefixed relative path`() { - assertEquals("./src/index.php", MagoAnnotatorProxy.ensureMagoPath("./src/index.php")) - assertEquals(".\\src\\index.php", MagoAnnotatorProxy.ensureMagoPath(".\\src\\index.php")) + assertEquals("./src/index.php", MagoCliOptions.ensureMagoPath("./src/index.php")) + assertEquals(".\\src\\index.php", MagoCliOptions.ensureMagoPath(".\\src\\index.php")) } fun `test ensureMagoPath with unprefixed relative path`() { - // Ожидаем, что добавится ./ val expected = "./src/index.php" - assertEquals(expected, MagoAnnotatorProxy.ensureMagoPath("src/index.php")) + assertEquals(expected, MagoCliOptions.ensureMagoPath("src/index.php")) } - fun `test toWorkspaceRelativePath with path inside project`() { + fun `test toRelativePath with path inside project`() { val basePath = project.basePath!! val filePath = File(basePath, "src/main.php").absolutePath val expected = "./src/main.php" - assertEquals(expected, MagoAnnotatorProxy.toRelativePath(basePath, filePath)) + assertEquals(expected, MagoCliOptions.toRelativePath(basePath, filePath)) } - fun `test toWorkspaceRelativePath with path outside project`() { + fun `test toRelativePath with path outside project`() { val filePath = "/tmp/other.php" - val result = MagoAnnotatorProxy.toRelativePath("", filePath) + val result = MagoCliOptions.toRelativePath("", filePath) assertTrue(result.contains("tmp/other.php")) } - fun `test toWorkspaceRelativePath with Windows paths`() { + fun `test toRelativePath with Windows paths`() { val basePath = "D:\\projects" val filePath = "D:\\projects\\src\\index.php" - val result = MagoAnnotatorProxy.toRelativePath(basePath, filePath) + val result = MagoCliOptions.toRelativePath(basePath, filePath) val expected = "./src/index.php" assertEquals(expected, result) } - fun `test toWorkspaceRelativePath with Windows paths2`() { + fun `test toRelativePath with Windows paths2`() { val basePath = "D:\\projects" val filePath = "\\\\?\\D:\\projects\\index.php" - val result = MagoAnnotatorProxy.toRelativePath(basePath, filePath) + val result = MagoCliOptions.toRelativePath(basePath, filePath) val expected = "./index.php" assertEquals(expected, result) } - fun `test toWorkspaceRelativePath with Unix paths`() { + fun `test toRelativePath with Unix paths`() { val basePath = "/home/user/project" val filePath = "/home/user/project/src/main.php" val relative = FileUtil.getRelativePath(basePath, filePath, '/') - val result = MagoAnnotatorProxy.ensureMagoPath(relative ?: filePath) + val result = MagoCliOptions.ensureMagoPath(relative ?: filePath) val expected = "./src/main.php" assertEquals(expected, result) @@ -75,13 +74,12 @@ class MagoAnnotatorProxyTest : BasePlatformTestCase() { val path1 = "\\\\?\\D:\\projects\\index.php" val path2 = "D:\\projects\\index.php" - assertEquals(path1, MagoAnnotatorProxy.ensureMagoPath(path1)) - assertEquals(path2, MagoAnnotatorProxy.ensureMagoPath(path2)) + assertEquals(path1, MagoCliOptions.ensureMagoPath(path1)) + assertEquals(path2, MagoCliOptions.ensureMagoPath(path2)) } - fun `test ensureMagoPath with Unix paths`() { val path = "/home/user/project/src/main.php" - assertEquals("/home/user/project/src/main.php", MagoAnnotatorProxy.ensureMagoPath(path)) + assertEquals("/home/user/project/src/main.php", MagoCliOptions.ensureMagoPath(path)) } } diff --git a/src/test/kotlin/com/github/xepozz/mago/analysis/MagoJsonMessageHandlerTest.kt b/src/test/kotlin/com/github/xepozz/mago/analysis/MagoJsonMessageHandlerTest.kt new file mode 100644 index 0000000..b0f4857 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/mago/analysis/MagoJsonMessageHandlerTest.kt @@ -0,0 +1,436 @@ +package com.github.xepozz.mago.analysis + +import com.github.xepozz.mago.model.MagoProblemDescription +import com.github.xepozz.mago.model.MagoSeverity +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class MagoJsonMessageHandlerTest : BasePlatformTestCase() { + private val handler = MagoJsonMessageHandler() + + fun `test empty object returns empty list`() { + assertEquals(emptyList(), handler.parseJson("{}", "analysis")) + } + + fun `test null JSON returns empty list`() { + assertEquals(emptyList(), handler.parseJson("null", "analysis")) + } + + fun `test empty issues returns empty list`() { + assertEquals(emptyList(), handler.parseJson("""{"issues": []}""", "analysis")) + } + + fun `test levelToSeverity mapping`() { + assertEquals(MagoSeverity.ERROR, handler.levelToSeverity("Error")) + assertEquals(MagoSeverity.WARNING, handler.levelToSeverity("Warning")) + assertEquals(MagoSeverity.INFO, handler.levelToSeverity("Help")) + assertEquals(MagoSeverity.INFO, handler.levelToSeverity("Note")) + assertEquals(MagoSeverity.INFO, handler.levelToSeverity("Unknown")) + assertEquals(MagoSeverity.INFO, handler.levelToSeverity(null)) + } + + fun `test parse single issue extracts all fields`() { + val json = $$""" + { + "issues": [{ + "code": "undefined-variable", + "level": "Error", + "message": "Undefined variable: `$res`.", + "help": "Check for typos.", + "notes": ["Did you mean `$result`?"], + "edits": [], + "annotations": [{ + "kind": "Primary", + "message": "Variable used here", + "span": { + "file_id": {"path": "/tmp/test.php"}, + "start": {"line": 5, "offset": 42}, + "end": {"offset": 46} + } + }] + }] + } + """.trimIndent() + + val problems = handler.parseJson(json, "analysis") + + assertEquals(1, problems.size) + val p = problems[0] + assertEquals("undefined-variable", p.code) + assertEquals(MagoSeverity.ERROR, p.severity) + assertEquals(5, p.lineNumber) + assertEquals(42, p.startChar) + assertEquals(46, p.endChar) + assertEquals($$"Undefined variable: `$res`", p.myMessage) + assertEquals("/tmp/test.php", p.myFile) + assertEquals("analysis", p.category) + assertEquals("Check for typos.", p.help) + assertEquals(listOf($$"Did you mean `$result`?"), p.notes) + } + + fun `test message trailing dot is trimmed`() { + val json = buildSingleIssueJson( + code = "test", + level = "Warning", + message = "Trailing dot removed.", + offset = 0 to 5 + ) + val problems = handler.parseJson(json, "analysis") + assertEquals("Trailing dot removed", problems[0].myMessage) + } + + fun `test message without trailing dot is unchanged`() { + val json = buildSingleIssueJson( + code = "test", + level = "Warning", + message = "No trailing dot", + offset = 0 to 5 + ) + val problems = handler.parseJson(json, "analysis") + assertEquals("No trailing dot", problems[0].myMessage) + } + + fun `test category is passed through`() { + val json = buildSingleIssueJson(code = "test-rule", level = "Warning", message = "Test.", offset = 0 to 5) + + assertEquals("lint", handler.parseJson(json, "lint")[0].category) + assertEquals("guard", handler.parseJson(json, "guard")[0].category) + assertEquals("analysis", handler.parseJson(json, "analysis")[0].category) + } + + fun `test Secondary annotations are filtered out by default`() { + val json = """ + { + "issues": [{ + "code": "some-check", + "level": "Error", + "message": "Some error.", + "help": "", + "notes": [], + "edits": [], + "annotations": [ + { + "kind": "Primary", + "message": "Primary annotation", + "span": { + "file_id": {"path": "/tmp/test.php"}, + "start": {"line": 1, "offset": 10}, + "end": {"offset": 15} + } + }, + { + "kind": "Secondary", + "message": "Secondary annotation", + "span": { + "file_id": {"path": "/tmp/test.php"}, + "start": {"line": 2, "offset": 20}, + "end": {"offset": 25} + } + } + ] + }] + } + """.trimIndent() + + val problems = handler.parseJson(json, "analysis") + assertEquals(1, problems.size) + assertEquals("Some error", problems[0].myMessage) + } + + fun `test type-inspection keeps Secondary annotations`() { + val json = """ + { + "issues": [{ + "code": "type-inspection", + "level": "Error", + "message": "Type mismatch.", + "help": "", + "notes": [], + "edits": [], + "annotations": [ + { + "kind": "Primary", + "message": "Primary", + "span": { + "file_id": {"path": "/tmp/test.php"}, + "start": {"line": 1, "offset": 10}, + "end": {"offset": 15} + } + }, + { + "kind": "Secondary", + "message": "Related type issue", + "span": { + "file_id": {"path": "/tmp/test.php"}, + "start": {"line": 2, "offset": 20}, + "end": {"offset": 25} + } + } + ] + }] + } + """.trimIndent() + + val problems = handler.parseJson(json, "analysis") + assertEquals(2, problems.size) + assertEquals("Type mismatch", problems[0].myMessage) + assertEquals("Related type issue", problems[1].myMessage) + } + + fun `test parse issue with edits and replacements`() { + val json = """ + { + "issues": [{ + "code": "missing-return-type", + "level": "Warning", + "message": "Missing return type.", + "help": "Add return type.", + "notes": [], + "edits": [ + [ + {"name": "Add void return type", "path": "/tmp/test.php"}, + [{"range": {"start": 50, "end": 50}, "new_text": ": void", "safety": "safe"}] + ] + ], + "annotations": [{ + "kind": "Primary", + "message": "Function missing return type", + "span": { + "file_id": {"path": "/tmp/test.php"}, + "start": {"line": 3, "offset": 20}, + "end": {"offset": 30} + } + }] + }] + } + """.trimIndent() + + val problems = handler.parseJson(json, "analysis") + assertEquals(1, problems.size) + + val edit = problems[0].edits.single() + assertEquals("Add void return type", edit.name) + assertEquals("/tmp/test.php", edit.path) + + val replacement = edit.replacements.single() + assertEquals(50, replacement.start) + assertEquals(50, replacement.end) + assertEquals(": void", replacement.newText) + assertEquals("safe", replacement.safety) + } + + fun `test multiple edits per issue`() { + val json = """ + { + "issues": [{ + "code": "fix-me", + "level": "Error", + "message": "Needs fixing.", + "help": "", + "notes": [], + "edits": [ + [ + {"name": "Fix A", "path": "/tmp/a.php"}, + [{"range": {"start": 10, "end": 15}, "new_text": "fixed_a", "safety": "safe"}] + ], + [ + {"name": "Fix B", "path": "/tmp/b.php"}, + [{"range": {"start": 20, "end": 25}, "new_text": "fixed_b", "safety": "potentially_unsafe"}] + ] + ], + "annotations": [{ + "kind": "Primary", + "message": "Error here", + "span": { + "file_id": {"path": "/tmp/a.php"}, + "start": {"line": 1, "offset": 10}, + "end": {"offset": 15} + } + }] + }] + } + """.trimIndent() + + val problems = handler.parseJson(json, "analysis") + assertEquals(2, problems[0].edits.size) + assertEquals("Fix A", problems[0].edits[0].name) + assertEquals("Fix B", problems[0].edits[1].name) + assertEquals("safe", problems[0].edits[0].replacements[0].safety) + assertEquals("potentially_unsafe", problems[0].edits[1].replacements[0].safety) + } + + fun `test Windows path prefix is stripped`() { + val json = """ + { + "issues": [{ + "code": "test", + "level": "Error", + "message": "Test.", + "help": "", + "notes": [], + "edits": [], + "annotations": [{ + "kind": "Primary", + "message": "Primary", + "span": { + "file_id": {"path": "\\\\?\\D:\\projects\\test.php"}, + "start": {"line": 1, "offset": 0}, + "end": {"offset": 5} + } + }] + }] + } + """.trimIndent() + val problems = handler.parseJson(json, "analysis") + assertEquals("D:/projects/test.php", problems[0].myFile) + } + + fun `test multiple issues produce multiple problems`() { + val json = """ + { + "issues": [ + { + "code": "error-one", + "level": "Error", + "message": "First.", + "help": "", + "notes": [], + "edits": [], + "annotations": [{ + "kind": "Primary", + "message": "First", + "span": { + "file_id": {"path": "/tmp/test.php"}, + "start": {"line": 1, "offset": 0}, + "end": {"offset": 5} + } + }] + }, + { + "code": "error-two", + "level": "Warning", + "message": "Second.", + "help": "", + "notes": [], + "edits": [], + "annotations": [{ + "kind": "Primary", + "message": "Second", + "span": { + "file_id": {"path": "/tmp/test.php"}, + "start": {"line": 3, "offset": 20}, + "end": {"offset": 30} + } + }] + } + ] + } + """.trimIndent() + + val problems = handler.parseJson(json, "analysis") + assertEquals(2, problems.size) + assertEquals("error-one", problems[0].code) + assertEquals("error-two", problems[1].code) + assertEquals(MagoSeverity.ERROR, problems[0].severity) + assertEquals(MagoSeverity.WARNING, problems[1].severity) + } + + fun `test issue with empty annotations returns no problems`() { + val json = """ + { + "issues": [{ + "code": "internal", + "level": "Error", + "message": "Internal error.", + "help": "", + "notes": [], + "edits": [], + "annotations": [] + }] + } + """.trimIndent() + + assertEquals(emptyList(), handler.parseJson(json, "analysis")) + } + + fun `test issue with missing span fields returns no problems`() { + val json = """ + { + "issues": [{ + "code": "broken", + "level": "Error", + "message": "Broken.", + "help": "", + "notes": [], + "edits": [], + "annotations": [{ + "kind": "Primary", + "message": "Missing span fields", + "span": { + "file_id": {"path": "/tmp/test.php"}, + "start": {}, + "end": {} + } + }] + }] + } + """.trimIndent() + + assertEquals(emptyList(), handler.parseJson(json, "analysis")) + } + + fun `test notes are extracted correctly`() { + val json = """ + { + "issues": [{ + "code": "test", + "level": "Error", + "message": "Test.", + "help": "Some help", + "notes": ["Note 1", "Note 2", "Note 3"], + "edits": [], + "annotations": [{ + "kind": "Primary", + "message": "Primary", + "span": { + "file_id": {"path": "/tmp/test.php"}, + "start": {"line": 1, "offset": 0}, + "end": {"offset": 5} + } + }] + }] + } + """.trimIndent() + + val problems = handler.parseJson(json, "analysis") + assertEquals(listOf("Note 1", "Note 2", "Note 3"), problems[0].notes) + } + + private fun buildSingleIssueJson( + code: String, + level: String, + message: String, + offset: Pair, + line: Int = 1, + filePath: String = "/tmp/test.php", + ): String = """ + { + "issues": [{ + "code": "$code", + "level": "$level", + "message": "$message", + "help": "", + "notes": [], + "edits": [], + "annotations": [{ + "kind": "Primary", + "message": "Primary", + "span": { + "file_id": {"path": "$filePath"}, + "start": {"line": $line, "offset": ${offset.first}}, + "end": {"offset": ${offset.second}} + } + }] + }] + } + """.trimIndent() +} diff --git a/src/test/kotlin/com/github/xepozz/mago/annotator/MagoAnnotationFunctionalTest.kt b/src/test/kotlin/com/github/xepozz/mago/annotator/MagoAnnotationFunctionalTest.kt new file mode 100644 index 0000000..c88b745 --- /dev/null +++ b/src/test/kotlin/com/github/xepozz/mago/annotator/MagoAnnotationFunctionalTest.kt @@ -0,0 +1,185 @@ +package com.github.xepozz.mago.annotator + +import com.github.xepozz.mago.analysis.MagoJsonMessageHandler +import com.github.xepozz.mago.model.MagoProblemDescription +import com.github.xepozz.mago.model.MagoSeverity +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.io.File + +class MagoAnnotationFunctionalTest : BasePlatformTestCase() { + private val handler = MagoJsonMessageHandler() + + override fun getTestDataPath() = "src/test/testData" + + private fun phpContent(caseName: String): String { + val dir = File(testDataPath, "magoFunctional/$caseName") + return dir.listFiles()?.first { it.extension == "php" }?.readText() + ?: error("No PHP file found in $dir") + } + + private fun magoJson(caseName: String): String = + File(testDataPath, "magoFunctional/$caseName/mago-output.json").readText() + + private fun textAtByteRange(content: String, byteStart: Int, byteEnd: Int): String { + val bytes = content.toByteArray(Charsets.UTF_8) + return String(bytes.sliceArray(byteStart until byteEnd), Charsets.UTF_8) + } + + // -- mixedArgumentSubstr -- + + fun `test mixedArgumentSubstr - single mixed-argument problem parsed`() { + val php = phpContent("mixedArgumentSubstr") + val problems = handler.parseJson(magoJson("mixedArgumentSubstr"), "analysis") + + assertEquals(1, problems.size) + assertEquals("mixed-argument", problems[0].code) + assertEquals(MagoSeverity.ERROR, problems[0].severity) + } + + fun `test mixedArgumentSubstr - byte offset points to dollar-var`() { + val php = phpContent("mixedArgumentSubstr") + val problems = handler.parseJson(magoJson("mixedArgumentSubstr"), "analysis") + + assertEquals("\$var", textAtByteRange(php, problems[0].startChar, problems[0].endChar)) + } + + fun `test mixedArgumentSubstr - line number is correct`() { + val php = phpContent("mixedArgumentSubstr") + val problems = handler.parseJson(magoJson("mixedArgumentSubstr"), "analysis") + val substrLine = php.lines().indexOfFirst { it.contains("substr(") } + + assertEquals(substrLine, problems[0].lineNumber) + } + + // -- multipleErrors -- + + fun `test multipleErrors - five problems parsed`() { + val problems = handler.parseJson(magoJson("multipleErrors"), "analysis") + assertEquals(5, problems.size) + } + + fun `test multipleErrors - mixed-assignment points to dollar-var`() { + val php = phpContent("multipleErrors") + val problems = handler.parseJson(magoJson("multipleErrors"), "analysis") + val p = problems.single { it.code == "mixed-assignment" } + + assertEquals(MagoSeverity.WARNING, p.severity) + assertEquals("\$var", textAtByteRange(php, p.startChar, p.endChar)) + } + + fun `test multipleErrors - mixed-argument on substr points to dollar-var`() { + val php = phpContent("multipleErrors") + val problems = handler.parseJson(magoJson("multipleErrors"), "analysis") + val p = problems.first { it.code == "mixed-argument" && it.lineNumber == 5 } + + assertEquals("\$var", textAtByteRange(php, p.startChar, p.endChar)) + } + + fun `test multipleErrors - too-many-arguments points to digit-3`() { + val php = phpContent("multipleErrors") + val problems = handler.parseJson(magoJson("multipleErrors"), "analysis") + val p = problems.single { it.code == "too-many-arguments" } + + assertEquals("3", textAtByteRange(php, p.startChar, p.endChar)) + } + + fun `test multipleErrors - undefined-variable points to dollar-res`() { + val php = phpContent("multipleErrors") + val problems = handler.parseJson(magoJson("multipleErrors"), "analysis") + val p = problems.single { it.code == "undefined-variable" } + + assertEquals("\$res", textAtByteRange(php, p.startChar, p.endChar)) + } + + fun `test multipleErrors - two problems share dollar-res range`() { + val problems = handler.parseJson(magoJson("multipleErrors"), "analysis") + val resProblems = problems.filter { it.lineNumber == 6 } + + assertEquals(2, resProblems.size) + val codes = resProblems.map { it.code }.toSet() + assertTrue(codes.contains("undefined-variable")) + assertTrue(codes.contains("mixed-argument")) + assertEquals(resProblems[0].startChar, resProblems[1].startChar) + assertEquals(resProblems[0].endChar, resProblems[1].endChar) + } + + fun `test multipleErrors - severities are correct`() { + val problems = handler.parseJson(magoJson("multipleErrors"), "analysis") + val bySeverity = problems.groupBy { it.severity } + + assertEquals(1, bySeverity[MagoSeverity.WARNING]?.size) + assertEquals(4, bySeverity[MagoSeverity.ERROR]?.size) + } + + // -- multipleErrorsDuplicated -- + + fun `test multipleErrorsDuplicated - six problems parsed`() { + val problems = handler.parseJson(magoJson("multipleErrorsDuplicated"), "analysis") + assertEquals(6, problems.size) + } + + fun `test multipleErrorsDuplicated - two too-many-arguments on different lines`() { + val php = phpContent("multipleErrorsDuplicated") + val problems = handler.parseJson(magoJson("multipleErrorsDuplicated"), "analysis") + val tooMany = problems.filter { it.code == "too-many-arguments" } + + assertEquals(2, tooMany.size) + assertFalse( + "Expected too-many-arguments on different lines", + tooMany[0].lineNumber == tooMany[1].lineNumber + ) + assertEquals("3", textAtByteRange(php, tooMany[0].startChar, tooMany[0].endChar)) + assertEquals("3", textAtByteRange(php, tooMany[1].startChar, tooMany[1].endChar)) + } + + fun `test multipleErrorsDuplicated - dollar-res shifted to correct line`() { + val php = phpContent("multipleErrorsDuplicated") + val problems = handler.parseJson(magoJson("multipleErrorsDuplicated"), "analysis") + val echoLine = php.lines().indexOfFirst { it.contains("echo") } + val resProblems = problems.filter { it.lineNumber == echoLine } + + assertEquals(2, resProblems.size) + for (p in resProblems) { + assertEquals("\$res", textAtByteRange(php, p.startChar, p.endChar)) + } + } + + fun `test multipleErrorsDuplicated - extra line does not affect earlier offsets`() { + val phpSingle = phpContent("multipleErrors") + val phpDup = phpContent("multipleErrorsDuplicated") + val singleProblems = handler.parseJson(magoJson("multipleErrors"), "analysis") + val dupProblems = handler.parseJson(magoJson("multipleErrorsDuplicated"), "analysis") + + val singleAssign = singleProblems.single { it.code == "mixed-assignment" } + val dupAssign = dupProblems.single { it.code == "mixed-assignment" } + assertEquals(singleAssign.startChar, dupAssign.startChar) + assertEquals(singleAssign.endChar, dupAssign.endChar) + assertEquals( + textAtByteRange(phpSingle, singleAssign.startChar, singleAssign.endChar), + textAtByteRange(phpDup, dupAssign.startChar, dupAssign.endChar) + ) + } + + // -- byte-to-text edge cases -- + + fun `test byte range extraction for ASCII`() { + val text = " Date: Wed, 25 Feb 2026 22:31:47 +0100 Subject: [PATCH 3/3] chore: update playground --- playground/2.php | 2 +- playground/composer.json | 4 +- playground/composer.lock | 446 ++++++++++---------- playground/mago-analysis-baseline.toml | 7 + playground/mago.toml | 1 + playground/out.json | 137 ------ playground/src/baseline.php | 3 + playground/src/good.php | 2 +- playground/src/guard.php | 8 +- playground/src/inspect.php | 14 + playground/src/remove-empty-file-action.php | 1 + playground/src/suppress-comments.php | 41 ++ 12 files changed, 303 insertions(+), 363 deletions(-) create mode 100644 playground/mago-analysis-baseline.toml delete mode 100644 playground/out.json create mode 100644 playground/src/baseline.php create mode 100644 playground/src/inspect.php create mode 100644 playground/src/remove-empty-file-action.php create mode 100644 playground/src/suppress-comments.php diff --git a/playground/2.php b/playground/2.php index b98d9e1..276c2e2 100644 --- a/playground/2.php +++ b/playground/2.php @@ -6,4 +6,4 @@ function my_func(int $int) { $a = 1 + ''; return str_repeat('a', $int); -} \ No newline at end of file +} diff --git a/playground/composer.json b/playground/composer.json index 6e4b3a5..b771a7e 100644 --- a/playground/composer.json +++ b/playground/composer.json @@ -1,7 +1,7 @@ { "require-dev": { - "carthage-software/mago": "1.0.0-beta.12", - "vimeo/psalm": "^6.13" + "carthage-software/mago": "*", + "vimeo/psalm": "*" }, "config": { "allow-plugins": { diff --git a/playground/composer.lock b/playground/composer.lock index 5ecf8e0..2eedef0 100644 --- a/playground/composer.lock +++ b/playground/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f15efe840d16747a2567be886df08965", + "content-hash": "ffe30c731e140c8e3c713d08be27efb1", "packages": [], "packages-dev": [ { @@ -319,16 +319,16 @@ }, { "name": "amphp/parallel", - "version": "v2.3.2", + "version": "v2.3.3", "source": { "type": "git", "url": "https://github.com/amphp/parallel.git", - "reference": "321b45ae771d9c33a068186b24117e3cd1c48dce" + "reference": "296b521137a54d3a02425b464e5aee4c93db2c60" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/parallel/zipball/321b45ae771d9c33a068186b24117e3cd1c48dce", - "reference": "321b45ae771d9c33a068186b24117e3cd1c48dce", + "url": "https://api.github.com/repos/amphp/parallel/zipball/296b521137a54d3a02425b464e5aee4c93db2c60", + "reference": "296b521137a54d3a02425b464e5aee4c93db2c60", "shasum": "" }, "require": { @@ -391,7 +391,7 @@ ], "support": { "issues": "https://github.com/amphp/parallel/issues", - "source": "https://github.com/amphp/parallel/tree/v2.3.2" + "source": "https://github.com/amphp/parallel/tree/v2.3.3" }, "funding": [ { @@ -399,7 +399,7 @@ "type": "github" } ], - "time": "2025-08-27T21:55:40+00:00" + "time": "2025-11-15T06:23:42+00:00" }, { "name": "amphp/parser", @@ -817,36 +817,33 @@ }, { "name": "carthage-software/mago", - "version": "1.0.0-beta.12", + "version": "1.13.3", "source": { "type": "git", "url": "https://github.com/carthage-software/mago.git", - "reference": "6323cb4c69a5d4988883890028ec918c8ea23b5d" + "reference": "565646d4b0af5301b923dd0f2735a5a8868f1e90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/carthage-software/mago/zipball/6323cb4c69a5d4988883890028ec918c8ea23b5d", - "reference": "6323cb4c69a5d4988883890028ec918c8ea23b5d", + "url": "https://api.github.com/repos/carthage-software/mago/zipball/565646d4b0af5301b923dd0f2735a5a8868f1e90", + "reference": "565646d4b0af5301b923dd0f2735a5a8868f1e90", "shasum": "" }, "require": { - "composer-plugin-api": "^2.6", - "php": "~8.1 || ~8.2 || ~8.3 || ~8.4" + "php": "~8.1 || ~8.2 || ~8.3 || ~8.4 || ~8.5 || ~8.6" }, - "require-dev": { - "composer/composer": "^2.8" + "suggest": { + "ext-curl": "To show binary download progress" }, "bin": [ "composer/bin/mago" ], - "type": "composer-plugin", - "extra": { - "class": "Mago\\MagoPlugin" - }, + "type": "library", "autoload": { - "psr-4": { - "Mago\\": "composer/" - } + "files": [ + "composer/functions.php", + "composer/internal.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -864,7 +861,7 @@ ], "support": { "issues": "https://github.com/carthage-software/mago/issues", - "source": "https://github.com/carthage-software/mago/tree/1.0.0-beta.12" + "source": "https://github.com/carthage-software/mago/tree/1.13.3" }, "funding": [ { @@ -872,7 +869,7 @@ "type": "github" } ], - "time": "2025-09-07T03:15:43+00:00" + "time": "2026-03-02T05:49:03+00:00" }, { "name": "composer/pcre", @@ -1098,22 +1095,22 @@ }, { "name": "danog/advanced-json-rpc", - "version": "v3.2.2", + "version": "v3.2.3", "source": { "type": "git", "url": "https://github.com/danog/php-advanced-json-rpc.git", - "reference": "aadb1c4068a88c3d0530cfe324b067920661efcb" + "reference": "ae703ea7b4811797a10590b6078de05b3b33dd91" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/danog/php-advanced-json-rpc/zipball/aadb1c4068a88c3d0530cfe324b067920661efcb", - "reference": "aadb1c4068a88c3d0530cfe324b067920661efcb", + "url": "https://api.github.com/repos/danog/php-advanced-json-rpc/zipball/ae703ea7b4811797a10590b6078de05b3b33dd91", + "reference": "ae703ea7b4811797a10590b6078de05b3b33dd91", "shasum": "" }, "require": { "netresearch/jsonmapper": "^5", "php": ">=8.1", - "phpdocumentor/reflection-docblock": "^4.3.4 || ^5.0.0" + "phpdocumentor/reflection-docblock": "^4.3.4 || ^5.0.0 || ^6" }, "replace": { "felixfbecker/php-advanced-json-rpc": "^3" @@ -1144,9 +1141,9 @@ "description": "A more advanced JSONRPC implementation", "support": { "issues": "https://github.com/danog/php-advanced-json-rpc/issues", - "source": "https://github.com/danog/php-advanced-json-rpc/tree/v3.2.2" + "source": "https://github.com/danog/php-advanced-json-rpc/tree/v3.2.3" }, - "time": "2025-02-14T10:55:15+00:00" + "time": "2026-01-12T21:07:10+00:00" }, { "name": "daverandom/libdns", @@ -1231,29 +1228,29 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -1273,9 +1270,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "felixfbecker/language-server-protocol", @@ -1454,33 +1451,38 @@ }, { "name": "league/uri", - "version": "7.5.1", + "version": "7.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "81fb5145d2644324614cc532b28efd0215bda430" + "reference": "4436c6ec8d458e4244448b069cc572d088230b76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", - "reference": "81fb5145d2644324614cc532b28efd0215bda430", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", + "reference": "4436c6ec8d458e4244448b069cc572d088230b76", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.5", - "php": "^8.1" + "league/uri-interfaces": "^7.8", + "php": "^8.1", + "psr/http-factory": "^1" }, "conflict": { "league/uri-schemes": "^1.0" }, "suggest": { "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", "ext-fileinfo": "to create Data URI from file contennts", "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", - "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", - "league/uri-components": "Needed to easily manipulate URI objects components", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -1508,6 +1510,7 @@ "description": "URI manipulation library", "homepage": "https://uri.thephpleague.com", "keywords": [ + "URN", "data-uri", "file-uri", "ftp", @@ -1520,9 +1523,11 @@ "psr-7", "query-string", "querystring", + "rfc2141", "rfc3986", "rfc3987", "rfc6570", + "rfc8141", "uri", "uri-template", "url", @@ -1532,7 +1537,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.5.1" + "source": "https://github.com/thephpleague/uri/tree/7.8.0" }, "funding": [ { @@ -1540,26 +1545,25 @@ "type": "github" } ], - "time": "2024-12-08T08:40:02+00:00" + "time": "2026-01-14T17:24:56+00:00" }, { "name": "league/uri-interfaces", - "version": "7.5.0", + "version": "7.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", - "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", "shasum": "" }, "require": { "ext-filter": "*", "php": "^8.1", - "psr/http-factory": "^1", "psr/http-message": "^1.1 || ^2.0" }, "suggest": { @@ -1567,6 +1571,7 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -1591,7 +1596,7 @@ "homepage": "https://nyamsprod.com" } ], - "description": "Common interfaces and classes for URI representation and interaction", + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", "homepage": "https://uri.thephpleague.com", "keywords": [ "data-uri", @@ -1616,7 +1621,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" }, "funding": [ { @@ -1624,20 +1629,20 @@ "type": "github" } ], - "time": "2024-12-08T08:18:47+00:00" + "time": "2026-01-15T06:54:53+00:00" }, { "name": "netresearch/jsonmapper", - "version": "v5.0.0", + "version": "v5.0.1", "source": { "type": "git", "url": "https://github.com/cweiske/jsonmapper.git", - "reference": "8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c" + "reference": "980674efdda65913492d29a8fd51c82270dd37bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c", - "reference": "8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c", + "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/980674efdda65913492d29a8fd51c82270dd37bb", + "reference": "980674efdda65913492d29a8fd51c82270dd37bb", "shasum": "" }, "require": { @@ -1673,22 +1678,22 @@ "support": { "email": "cweiske@cweiske.de", "issues": "https://github.com/cweiske/jsonmapper/issues", - "source": "https://github.com/cweiske/jsonmapper/tree/v5.0.0" + "source": "https://github.com/cweiske/jsonmapper/tree/v5.0.1" }, - "time": "2024-09-08T10:20:00+00:00" + "time": "2026-02-22T16:28:03+00:00" }, { "name": "nikic/php-parser", - "version": "v5.6.1", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -1731,9 +1736,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-08-13T20:13:15+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -1790,16 +1795,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.3", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9" + "reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/94f8051919d1b0369a6bcc7931d679a511c03fe9", - "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/897b5986ece6b4f9d8413fea345c7d49c757d6bf", + "reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf", "shasum": "" }, "require": { @@ -1807,9 +1812,9 @@ "ext-filter": "*", "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1" + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { "mockery/mockery": "~1.3.5 || ~1.6.0", @@ -1818,7 +1823,8 @@ "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" }, "type": "library", "extra": { @@ -1848,44 +1854,44 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.3" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.2" }, - "time": "2025-08-01T19:43:32+00:00" + "time": "2026-03-01T18:43:49+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.10.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a" + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", + "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" + "phpstan/phpdoc-parser": "^2.0" }, "require-dev": { "ext-tokenizer": "*", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" + "psalm/phar": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -1906,22 +1912,22 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" }, - "time": "2024-11-09T15:12:26+00:00" + "time": "2026-01-06T21:53:42+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.0", + "version": "2.3.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", "shasum": "" }, "require": { @@ -1953,9 +1959,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" }, - "time": "2025-08-30T15:50:23+00:00" + "time": "2026-01-25T14:56:51+00:00" }, { "name": "psr/container", @@ -2170,16 +2176,16 @@ }, { "name": "revolt/event-loop", - "version": "v1.0.7", + "version": "v1.0.8", "source": { "type": "git", "url": "https://github.com/revoltphp/event-loop.git", - "reference": "09bf1bf7f7f574453efe43044b06fafe12216eb3" + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/09bf1bf7f7f574453efe43044b06fafe12216eb3", - "reference": "09bf1bf7f7f574453efe43044b06fafe12216eb3", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c", "shasum": "" }, "require": { @@ -2236,35 +2242,35 @@ ], "support": { "issues": "https://github.com/revoltphp/event-loop/issues", - "source": "https://github.com/revoltphp/event-loop/tree/v1.0.7" + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.8" }, - "time": "2025-01-25T19:27:39+00:00" + "time": "2025-08-27T21:33:23+00:00" }, { "name": "sebastian/diff", - "version": "7.0.0", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + "reference": "a2b6d09d7729ee87d605a439469f9dcc39be5ea3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a2b6d09d7729ee87d605a439469f9dcc39be5ea3", + "reference": "a2b6d09d7729ee87d605a439469f9dcc39be5ea3", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0", + "phpunit/phpunit": "^13.0", "symfony/process": "^7.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -2297,28 +2303,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/diff/tree/8.0.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" } ], - "time": "2025-02-07T04:55:46+00:00" + "time": "2026-02-06T04:42:27+00:00" }, { "name": "spatie/array-to-xml", - "version": "3.4.0", + "version": "3.4.4", "source": { "type": "git", "url": "https://github.com/spatie/array-to-xml.git", - "reference": "7dcfc67d60b0272926dabad1ec01f6b8a5fb5e67" + "reference": "88b2f3852a922dd73177a68938f8eb2ec70c7224" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/7dcfc67d60b0272926dabad1ec01f6b8a5fb5e67", - "reference": "7dcfc67d60b0272926dabad1ec01f6b8a5fb5e67", + "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/88b2f3852a922dd73177a68938f8eb2ec70c7224", + "reference": "88b2f3852a922dd73177a68938f8eb2ec70c7224", "shasum": "" }, "require": { @@ -2361,7 +2379,7 @@ "xml" ], "support": { - "source": "https://github.com/spatie/array-to-xml/tree/3.4.0" + "source": "https://github.com/spatie/array-to-xml/tree/3.4.4" }, "funding": [ { @@ -2373,51 +2391,43 @@ "type": "github" } ], - "time": "2024-12-16T12:45:15+00:00" + "time": "2025-12-15T09:00:41+00:00" }, { "name": "symfony/console", - "version": "v7.3.3", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7" + "reference": "488285876e807a4777f074041d8bb508623419fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7", - "reference": "cb0102a1c5ac3807cf3fdf8bea96007df7fdbea7", + "url": "https://api.github.com/repos/symfony/console/zipball/488285876e807a4777f074041d8bb508623419fa", + "reference": "488285876e807a4777f074041d8bb508623419fa", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2" - }, - "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" + "symfony/string": "^7.4|^8.0" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -2451,7 +2461,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.3" + "source": "https://github.com/symfony/console/tree/v8.0.6" }, "funding": [ { @@ -2471,7 +2481,7 @@ "type": "tidelift" } ], - "time": "2025-08-25T06:35:40+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/deprecation-contracts", @@ -2542,25 +2552,25 @@ }, { "name": "symfony/filesystem", - "version": "v7.3.2", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd" + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/edcbb768a186b5c3f25d0643159a787d3e63b7fd", - "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", + "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^6.4|^7.0" + "symfony/process": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -2588,7 +2598,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.3.2" + "source": "https://github.com/symfony/filesystem/tree/v8.0.6" }, "funding": [ { @@ -2608,7 +2618,7 @@ "type": "tidelift" } ], - "time": "2025-07-07T08:17:47+00:00" + "time": "2026-02-25T16:59:43+00:00" }, { "name": "symfony/polyfill-ctype", @@ -2862,20 +2872,19 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", "shasum": "" }, "require": { - "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -2923,7 +2932,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" }, "funding": [ { @@ -2934,16 +2943,12 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-php84", @@ -3027,16 +3032,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -3090,7 +3095,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -3101,44 +3106,47 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-25T09:37:31+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "symfony/string", - "version": "v7.3.3", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c" + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/17a426cce5fd1f0901fefa9b2a490d0038fd3c9c", - "reference": "17a426cce5fd1f0901fefa9b2a490d0038fd3c9c", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1", - "symfony/error-handler": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3177,7 +3185,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.3" + "source": "https://github.com/symfony/string/tree/v8.0.6" }, "funding": [ { @@ -3197,20 +3205,20 @@ "type": "tidelift" } ], - "time": "2025-08-25T06:35:40+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "vimeo/psalm", - "version": "6.13.1", + "version": "6.15.1", "source": { "type": "git", "url": "https://github.com/vimeo/psalm.git", - "reference": "1e3b7f0a8ab32b23197b91107adc0a7ed8a05b51" + "reference": "28dc127af1b5aecd52314f6f645bafc10d0e11f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vimeo/psalm/zipball/1e3b7f0a8ab32b23197b91107adc0a7ed8a05b51", - "reference": "1e3b7f0a8ab32b23197b91107adc0a7ed8a05b51", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/28dc127af1b5aecd52314f6f645bafc10d0e11f9", + "reference": "28dc127af1b5aecd52314f6f645bafc10d0e11f9", "shasum": "" }, "require": { @@ -3233,11 +3241,11 @@ "fidry/cpu-core-counter": "^0.4.1 || ^0.5.1 || ^1.0.0", "netresearch/jsonmapper": "^5.0", "nikic/php-parser": "^5.0.0", - "php": "~8.1.31 || ~8.2.27 || ~8.3.16 || ~8.4.3", - "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0", + "php": "~8.1.31 || ~8.2.27 || ~8.3.16 || ~8.4.3 || ~8.5.0", + "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", "spatie/array-to-xml": "^2.17.0 || ^3.0", - "symfony/console": "^6.0 || ^7.0", - "symfony/filesystem": "~6.3.12 || ~6.4.3 || ^7.0.3", + "symfony/console": "^6.0 || ^7.0 || ^8.0", + "symfony/filesystem": "~6.3.12 || ~6.4.3 || ^7.0.3 || ^8.0", "symfony/polyfill-php84": "^1.31.0" }, "provide": { @@ -3259,7 +3267,7 @@ "psalm/plugin-phpunit": "^0.19", "slevomat/coding-standard": "^8.4", "squizlabs/php_codesniffer": "^3.6", - "symfony/process": "^6.0 || ^7.0" + "symfony/process": "^6.0 || ^7.0 || ^8.0" }, "suggest": { "ext-curl": "In order to send data to shepherd", @@ -3315,37 +3323,37 @@ "issues": "https://github.com/vimeo/psalm/issues", "source": "https://github.com/vimeo/psalm" }, - "time": "2025-08-06T10:10:28+00:00" + "time": "2026-02-07T19:27:16+00:00" }, { "name": "webmozart/assert", - "version": "1.11.0", + "version": "2.1.6", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", "shasum": "" }, "require": { "ext-ctype": "*", - "php": "^7.2 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<0.12.20", - "vimeo/psalm": "<4.6.1 || 4.6.2" + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" }, - "require-dev": { - "phpunit/phpunit": "^8.5.13" + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.10-dev" + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -3361,6 +3369,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -3371,19 +3383,17 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.11.0" + "source": "https://github.com/webmozarts/assert/tree/2.1.6" }, - "time": "2022-06-03T18:03:27+00:00" + "time": "2026-02-27T10:28:38+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "carthage-software/mago": 10 - }, + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": {}, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/playground/mago-analysis-baseline.toml b/playground/mago-analysis-baseline.toml new file mode 100644 index 0000000..79a0c74 --- /dev/null +++ b/playground/mago-analysis-baseline.toml @@ -0,0 +1,7 @@ +variant = "loose" + +[[issues]] +file = "src/baseline.php" +code = "non-existent-function" +message = "Function `doesNotExist` could not be found." +count = 1 diff --git a/playground/mago.toml b/playground/mago.toml index 6850572..b17b207 100644 --- a/playground/mago.toml +++ b/playground/mago.toml @@ -21,6 +21,7 @@ literal-named-argument = { enabled = false } halstead = { effort-threshold = 7000 } [analyzer] +baseline = "mago-analysis-baseline.toml" find-unused-definitions = true find-unused-expressions = false analyze-dead-code = false diff --git a/playground/out.json b/playground/out.json deleted file mode 100644 index a2d4a52..0000000 --- a/playground/out.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "issues": [ - { - "level": "Error", - "code": "invalid-operand", - "message": "Invalid type for left operand.", - "notes": [ - "The type(s) of the left operand are not compatible with this binary operation." - ], - "help": "Ensure the left operand has a type suitable for this operation (e.g., number for arithmetic, string for concatenation).", - "annotations": [ - { - "message": "Cannot perform arithmetic operation with non-numeric type string('text')", - "kind": "Secondary", - "span": { - "file_id": { - "name": "1.php", - "path": "/Users/xepozz/IdeaProjects/j-plugins/mago-plugin/playground/1.php", - "size": 67, - "file_type": "Host" - }, - "start": { - "offset": 51, - "line": 5 - }, - "end": { - "offset": 53, - "line": 5 - } - } - } - ] - }, - { - "level": "Warning", - "code": "mixed-assignment", - "message": "Assigning `mixed` type to a variable may lead to unexpected behavior.", - "notes": [ - "Using `mixed` can lead to runtime errors if the variable is used in a way that assumes a specific type." - ], - "help": "Consider using a more specific type to avoid potential issues.", - "annotations": [ - { - "message": "This expression has type `mixed`.", - "kind": "Secondary", - "span": { - "file_id": { - "name": "1.php", - "path": "/Users/xepozz/IdeaProjects/j-plugins/mago-plugin/playground/1.php", - "size": 67, - "file_type": "Host" - }, - "start": { - "offset": 51, - "line": 5 - }, - "end": { - "offset": 55, - "line": 5 - } - } - }, - { - "message": "Assigning `mixed` type here.", - "kind": "Primary", - "span": { - "file_id": { - "name": "1.php", - "path": "/Users/xepozz/IdeaProjects/j-plugins/mago-plugin/playground/1.php", - "size": 67, - "file_type": "Host" - }, - "start": { - "offset": 46, - "line": 5 - }, - "end": { - "offset": 48, - "line": 5 - } - } - } - ] - }, - { - "level": "Error", - "code": "mixed-argument", - "message": "The first value for `echo` is too general.", - "notes": [ - "The expected type `null|scalar` is more specific." - ], - "help": "Add a specific type hint or assertion for this value.", - "annotations": [ - { - "message": "Type `mixed` is too broad", - "kind": "Primary", - "span": { - "file_id": { - "name": "1.php", - "path": "/Users/xepozz/IdeaProjects/j-plugins/mago-plugin/playground/1.php", - "size": 67, - "file_type": "Host" - }, - "start": { - "offset": 63, - "line": 7 - }, - "end": { - "offset": 65, - "line": 7 - } - } - }, - { - "message": "`echo` called here", - "kind": "Secondary", - "span": { - "file_id": { - "name": "1.php", - "path": "/Users/xepozz/IdeaProjects/j-plugins/mago-plugin/playground/1.php", - "size": 67, - "file_type": "Host" - }, - "start": { - "offset": 58, - "line": 7 - }, - "end": { - "offset": 62, - "line": 7 - } - } - } - ] - } - ] -} \ No newline at end of file diff --git a/playground/src/baseline.php b/playground/src/baseline.php new file mode 100644 index 0000000..1e2a4a4 --- /dev/null +++ b/playground/src/baseline.php @@ -0,0 +1,3 @@ +