Привет, ранее я написал статью о своем плагине и том, как переосмыслил подход к получению проектной модели Maven. И обещал более подробно рассказать о технических деталях реализации плагина и точках расширения IDEA, которые использовал. Кстати, если у вас есть Maven-проекты и вы еще не пробовали мой плагин, то был бы очень признателен, если бы вы попробовали и оставили обратную связь. Это помогает мне сделать проект лучше, находить и устранять проблемы. А теперь к делу.

Большинство так называемых внешних систем, по отношению к IDE, соответствуют общему шаблону (не обязательно все пункты):

  • имеют конфигурационные файлы;

  • на основании конфигурационных файлов можно построить проект;

  • предоставляют возможность выполнения каких-либо задач;

Всем этим условиям удовлетворяют популярные билд-системы: Gradle, Maven, Sbt и не только, например Docker-compose. IDEA плагины для них построены на External System API (специальный набор точек расширения и готовых сервисов для интеграции с внешними системами) и используют его в той или иной степени. Поэтому для написания своего плагина, я также использовал данное API и хочу рассказать о нем более подробно т.к. официальная документация дает только верхнеуровневый обзор.

Задача

Чтобы не просто перечислять классы и интерфейсы, я решил рассмотреть их на примере создания своего плагина для некой абстрактной билд-системы. С конфигурационным билд файлом в формате JSON:

{
  "groupId": "ru.rzn.build.json.system",
  "artifactId": "simple-project",
  "version": "1.0-SNAPSHOT",
  "sources": ["src/main/java"],
  "resources": ["src/main/resources"],
  "sourcesTest": ["src/test/java"],
  "resourcesTest": ["src/test/resources"],
  "compilerJdkVersion": "11",
  "compilerArgs": ["-verbose", "-Xlint:all"],
  "dependencies": [
    {
      "groupId": "org.hamcrest",
      "artifactId": "hamcrest-core",
      "version": "4.12",
      "relativePath": "lib/hamcrest-core-2.1.jar"
    }
  ],
  "modules": []
}

Для простоты задачи, чтобы сократить объем кода, сделаем допущения, что artifactId равен имени директории, где находится модуль/под-модуль, имя билд файла строго равно build.json и билд-файл не может содержать null значений.

Желательно также, чтобы читатель уже был знаком с базовыми понятиями "плагинописания" для IDEA, пробовал создавать простые проекты, имел представление о Project Structure (ctrl+alt+shift + s) и знал, что такое plugin.xml и зачем он нужен.

Project Data Domain

Первое, что надо сделать - это обработать конфигурационные файлы и создать на их основе модель данных. Модель данных в External System API представляет из себя древовидную структуру. Основными ее классами являются:

  • DataNode - элемент вершины дерева, который хранит непосредственно данные и имеет ссылку на родителя и дочерние элементы;

  • Key - ключ с которым ассоциированы данные конкретного типа;

  • ExternalNodeData - непосредственно сами данные.

Визуально это выглядит вот так:

Для большинства случаев уже созданы готовые классы данных:

  • ProjectData - данные о проекте;

  • ModuleData - данные о модуле внутри проекта;

  • ContentRootData - данные о каталогах внутри модуля(main/test/resources);

  • TaskData - данные о задаче, которые может выполнять внешняя система;

  • LibraryDependencyData - данные о зависимостях проекта и/или модуля и т.д.

Для этих элементов также есть готовые обработчики, которые отвечают за создание элементов в внутренней структуре IDEA - Project Structure, но об этом чуть позже.

Обработка конфигурационных файлов

Когда мы уже познакомились с моделью данных, перейдем непосредственно к обработке конфигурационных файлов. Нужно представить их содержимое в виде дерева - DataNode<Key, ExternalNodeData>.

У каждой внешней системы должен быть свой уникальный id, чтобы отличать их друг от друга и понимать, какие данные какой системой созданы. Для этого нам нужно создать константу ProjectSystemId:

object Constants {
    const val JSON_BUILD_SYSTEM = "JsonBuildSystem"
    val SYSTEM_ID = ProjectSystemId(JSON_BUILD_SYSTEM.uppercase(Locale.getDefault()), JSON_BUILD_SYSTEM)
}

Для получения проектной модели нужно реализовать интерфейс ExternalSystemProjectResolver, основной метод которого отвечает за то чтобы создать и вернуть модель данных - resolveProjectInfo. Ниже частично приведена реализация для нашей абстрактной билд-системы.

class JsonBuildSystemProjectResolver : ExternalSystemProjectResolver<ExecutionSettings> {

   override fun resolveProjectInfo(
       id: ExternalSystemTaskId,
       projectPath: String,
       isPreviewMode: Boolean,
       settings: ExecutionSettings?,
       listener: ExternalSystemTaskNotificationListener
   ): DataNode<ProjectData> {
       settings ?: throw ExternalSystemException("settings is empty")
       val configPath = settings.configPath ?: throw ExternalSystemException("config paths is empty")
       val buildModel = JsonBuildSystemUtils.fromJson(configPath)
       val languageLevel = getLanguageLevel(buildModel)
       val mainModulePath = Path.of(configPath).parent

       val projectDataNode = createProjectNode(buildModel, mainModulePath)

       projectDataNode.createChild(ProjectKeys.TASK, TaskData(SYSTEM_ID, "clean", mainModulePath.toString(), null))

       setupJdkNodes(settings, projectDataNode, mainModulePath, languageLevel)
       setupModulesData(buildModel, mainModulePath.parent, projectDataNode)
       listener.onTaskOutput(id, "import finished", true)
       return projectDataNode
   }
}

На вход к нам поступает внутренний id задачи, корневая директория проекта, настройки исполнения (о настройках более подробно будет далее), listener для событий процесса. Далее мы парсим наш JSON-файл, создаем вершину с проектом DataNode<ProjectData>, к которой уже добавляем остальные узлы - информацию о задачах, модулях их зависимостях и директориях, SDK и прочее.

По умолчанию данный сервис поднимается в отдельном процессе, но нам этого не надо, поэтому отключаем это поведение в plugin.xml.

<registryKey key="JSONBUILDSYSTEM.system.in.process" defaultValue="true"/>

В моем GMaven и также в Gradle-плагине, сделано аналогично. Потому-что получение проектной модели как правило делегируется билд-системе, а она уже стартует отдельный процесс.

Код project resolver'а по сути является просто мэппингом данных из проектной структуры билд-системы в модель данных External System, где для подавляющего числа элементов уже есть готовые классы модели данных и обработчики для них, которые позволяют на основе этих данных создавать элементы проектной структуры IDEA: проекты, модули, зависимости, таски, а также создавать для них визуальное представление.

Для того чтобы программно получить созданную резолвером модель данных внешней системы, нужно вызвать:

ProjectDataManager.getInstance()
  .getExternalProjectData(project, SYSTEM_ID, externalProjectPath)
  ?.getExternalProjectStructure();

Уже такая простая реализация, если её сконфигурировать до конца, годится для того чтобы создавать проект внутри IDE и визуально отображать данные в билд-окне.

Но мы не будем на этом останавливаться, а пойдем дальше.

ProjectDataService & ExternalSystemViewContributor

Теперь более детально поговорим о создании своих элементов модели данных. Если внимательно пройтись по реализации нашего резолвера, то можно увидеть, что мы добавили два кастомных элемента.

Первый - это CompilerArgData для хранения аргументов компилятора. Чтобы на основании этих данных иметь возможность делать какую-то полезную работу, а именно добавлять параметры компилятора к модулю, нужно реализовать интерфейс ProjectDataService.

Основные методы:

  • getTargetDataKey - ключ данных, которые будет обрабатывать сервис;

  • importData - создание/обновление элементов IDE; на вход поступают данные модели, что соответсвуют ключу;

  • computeOrphanData - вычисление удаленных данных, которые не представлены в текущей версии модели данных;

  • removeData непосредственно удаление данных.

Пример реализации для нашей билд-системы:

class CompilerArgDataService : AbstractProjectDataService<CompilerArgData, Void>() {
   override fun getTargetDataKey() = CompilerArgData.KEY

   override fun importData(
       toImport: Collection<DataNode<CompilerArgData>>,
       projectData: ProjectData?,
       project: Project,
       modifiableModelsProvider: IdeModifiableModelsProvider
   ) {
       val config = CompilerConfiguration.getInstance(project) as CompilerConfigurationImpl
       for (node in toImport) {
           val moduleData = node.parent?.data as? ModuleData ?: continue
           val ideModule = modifiableModelsProvider.findIdeModule(moduleData) ?: continue
           config.setAdditionalOptions(ideModule, ArrayList(node.data.arguments))
       }
   }
}

Для каждого элемента CompilerArgData, мы получаем его родителя - данные о модуле к которому относятся данные настройки и добавляем эти настройки к нему. Благодаря входным параметрам данного метода, можно получить доступ практически любому функционалу IDEA. Удалять в данном случае ничего не нужно, т.к. настройки привязаны строго к модулю, и если он удаляется, то удаляются и настройки.

Для лучшего понимания, можно посмотреть более сложный пример готового обработчика, представленного в External System, который создает/обновляет/удаляет модули проектной структуры IDEA ModuleDataService

Также нужно не забыть зарегистрировать данный сервис в plugin.xml.

<externalProjectDataService implementation="ru.rzn.gmyasoedov.jsonbuildsystem.project.service.CompilerArgDataService"/>

Второй наш элемент модели данных - это BuildActionData, т.к. наша билд система ничего не умеет и это просто JSON-файл, то нам нужен способ собирать проект. Для этого мы и создаем данный узел в дереве модели данных, чтобы отображать его в билд-окне, на одном уровне с "тасками" (можно посмотреть как это выглядит на картинке выше) и по клику запускать процесс сборки проекта, используя уже готовый обработчик, который делает это через меню IDEA Build | Build Project.

Сначала нужно создать наследника ExternalSystemNode.

@Order(ExternalSystemNode.BUILTIN_TASKS_DATA_NODE_ORDER)
class BuildActionViewNode(externalProjectsView: ExternalProjectsView, dataNode: DataNode<BuildActionData>) : ExternalSystemNode<BuildActionData>(externalProjectsView, null, dataNode) {
   override fun update(presentation: PresentationData) {
       super.update(presentation)
       presentation.setIcon(AllIcons.Nodes.ConfigFolder)
   }

   override fun getName() = "Build"

   override fun getActionId() = "CompileProject"
}

Это элемент модели данных UI, который будет непосредственно отображаться в билд-окне. Для него мы задаем иконку, имя и actionId. В данном случае мы берем уже готовый actionId который отвечает за сборку проекта. Также через аннотацию @Order указываем на каком уровне будут отображен элемент в дереве - на одном уровне с тасками. Теперь нужно реализовать ExternalSystemViewContributor, чтобы смэппить данные из модели проекта в модель для UI:

class JsonBuildSystemExternalViewContributor : ExternalSystemViewContributor() {
   override fun getSystemId() = SYSTEM_ID

   override fun getKeys(): List<Key<*>> = listOf(BuildActionData.KEY)

   override fun createNodes(
       externalProjectsView: ExternalProjectsView,
       dataNodes: MultiMap<Key<*>?, DataNode<*>?>
   ): List<ExternalSystemNode<*>> {
       val buildActionNodes = dataNodes[BuildActionData.KEY]
       return buildActionNodes.map { BuildActionViewNode(externalProjectsView, it as DataNode<BuildActionData>) }
   }
}

Здесь мы указываем на какие ключи будет реагировать обработчик и далее для элементов BuildActionData, которые у нас находятся в дереве данных по ключу BuildActionData.KEY, мы создаем и возвращаем уже UI элементы дерева для отображении в билд-окне. Регистрируем сервис в plugin.xml.

<externalSystemViewContributor id="JsonBuildSystem" implementation="ru.rzn.gmyasoedov.jsonbuildsystem.view.JsonBuildSystemExternalViewContributor"/>

В заключении по данному разделу можно сказать, что элемент данных не обязан иметь какие либо обработчики, но тогда он и не будет ничего уметь делать. Он может иметь один из двух типов обработчиков или все сразу. Например, для ModuleData в External System API уже есть обработчики чтобы отображать данный узел визуально в дереве и на его основании создавать модуль в проектной структуре IDE. И да, на один элемент данных может быть "повешено" несколько обработчиков - ProjectDataService.

Выполнение задач

Для выполнения задач билд-системы нужно реализовать интерфейс ExternalSystemTaskManager, но т.к. наша абстрактная билд-система это просто файлик, который ничего не умеет, то для примера реализуем только clean-таск для очистки билд-директорий. Никто не мешает нам пройтись рекурсивно по всем модулям и очистить их target-папки.

class JsonBuildSystemTaskManager : ExternalSystemTaskManager<ExecutionSettings> {

   override fun executeTasks(
       id: ExternalSystemTaskId,
       taskNames: MutableList<String>,
       projectPath: String,
       settings: ExecutionSettings?,
       jvmParametersSetup: String?,
       listener: ExternalSystemTaskNotificationListener
   ) {
       if (taskNames != listOf("clean")) {
            throw ExternalSystemException("only clean implemented")
       }
       settings ?: throw ExternalSystemException("settings is empty")
       val configPath = settings.configPath ?: throw ExternalSystemException("config paths is empty")
       JsonBuildSystemUtils.getAllModulesWithPath(configPath)
           .map { it.modelPath.resolve("target") }
           .forEach { FileUtil.deleteRecursively(it) }
   }
}

Также необходимо создать классы JsonBuildSystemRuntimeConfigurationProducer, JsonBuildSystemRunConfigurationExtension и JsonBuildSystemExternalTaskConfigurationType для интеграции с Run Configurations. Разбирать их содержимое тут я не буду, они достаточно простые. Их нужно зарегистрировать в plugin.xml.

<runConfigurationProducer implementation="ru.rzn.gmyasoedov.jsonbuildsystem.project.execution.JsonBuildSystemRuntimeConfigurationProducer"/>
<configurationType implementation="ru.rzn.gmyasoedov.jsonbuildsystem.project.execution.JsonBuildSystemExternalTaskConfigurationType"/>
<externalSystem.runConfigurationEx implementation="ru.rzn.gmyasoedov.jsonbuildsystem.project.execution.JsonBuildSystemRunConfigurationExtension"/>

Настройки

Для внешней системы нужно создать четыре вида настроек.

Первое - это настройки, непосредственно относящиеся к проекту ExternalProjectSettings:

class ProjectSettings : ExternalProjectSettings() {
   var configPath: String? = null
   var vmOptions: String? = null
   var jdkName: String? = null
}

Настройки нашей билд-системы содержат три дополнительных поля:

  • configPath - это полный путь к конфигурационному JSON файлу проекта;

  • vmOptions - просто для примера, чтобы дальше показать как редактировать настройки через UI;

  • jdkName - имя JDK, нужно для установки projectJDK на которой будет, работать наш проект.

Второй тип настроек - это глобальные настройки для всех проектов нашей билд-системы: AbstractExternalSystemSettings:

@State(name = "JsonBuildSystemSettings", storages = [Storage("jsonBuildSystem.xml")])
class SystemSettings(project: Project) :
   AbstractExternalSystemSettings<SystemSettings, ProjectSettings, SettingsListener>(
       SettingsListener.TOPIC, project
   ), PersistentStateComponent<SystemSettingsState> {

   var skipTests = false

   override fun copyExtraSettingsFrom(settings: SystemSettings) {}

   override fun checkSettings(old: ProjectSettings, current: ProjectSettings) {}

   override fun loadState(state: SystemSettingsState) {
       super.loadState(state)
       skipTests = state.skipTests
   }

   override fun getState(): SystemSettingsState {
       val state = SystemSettingsState()
       fillState(state)
       state.skipTests = skipTests
       return state
   }

   override fun getLinkedProjectSettings(projectPath: String): ProjectSettings? {
       val projectAbsolutePath = Path.of(projectPath).toAbsolutePath()
       val projectSettings: ProjectSettings? = super.getLinkedProjectSettings(projectPath)
       if (projectSettings == null) {
           for (setting in linkedProjectsSettings) {
               val settingPath = Path.of(setting.externalProjectPath).toAbsolutePath()
               if (FileUtil.isAncestor(settingPath.toFile(), projectAbsolutePath.toFile(), false)) {
                   return setting
               }
           }
       }
       return projectSettings
   }

   override fun subscribe(listener: ExternalSystemSettingsListener<ProjectSettings?>, parentDisposable: Disposable) {
   }
}

Глобальные настройки содержат одно дополнительное поле skipTests - также для примера, чтобы показать редактирование настроек через UI.

Данный тип настроек агрегирует в себе, настройки для каждого из проектов созданных на основе нашей билд системы (AbstractExternalSystemSettings#myLinkedProjectsSettings). Чтобы получить настройки конкретного проекта, нужно реализовать метод getLinkedProjectSettings, где мы, по указанному пути, находим нужный проект и возвращаем его ProjectSettings. Настройки проекта регистрируются в myLinkedProjectsSettings по родительской директории проекта. Тут нужно задать @State чтобы настройки не сбрасывались каждый раз при выходе из IDE и сохранялись на диске. Также нужно обязательно реализовать методы для загрузки и получения состояния: loadState, getState и метод subscribe для реагирования на изменение настроек. Но subscribe, для простоты, мы оставим пустым. Но в целом тут можно программно запустить процесс обновления проектной модели в случае изменения настроек:

ExternalSystemUtil.refreshProject(externalProjectPath, ImportSpecBuilder(project, SYSTEM_ID))

Следующий тип настроек, это так называемые локальные настройки - AbstractExternalSystemLocalSettings.

@State(name = "JsonBuildSystemLocalSettings", storages = [Storage(StoragePathMacros.CACHE_FILE)])
class LocalSettings(project: Project) : AbstractExternalSystemLocalSettings<LocalSettings.JsonLocalState>(
   Constants.SYSTEM_ID, project, JsonLocalState()
) {
   class JsonLocalState : State()
}

Содержат состояние локального workspace'a, включая модель данных External System для текущих проектов. Чтобы не перечитывать модель данных при каждом открытии IDE, а брать данные сразу из локальных настроек. В общем тут ничего своего добавлять не надо, просто создаем наследника под нашу билд систему с ее SYSTEM_ID и также указываем, что их надо сохранять на диск.

И последний тип настроек, это настройки исполнения - ExternalSystemExecutionSettings, которые далее передаются во все сервисы, где идет обращения к внешней системе: JsonBuildSystemTaskManager, JsonBuildSystemProjectResolver.

class ExecutionSettings : ExternalSystemExecutionSettings() {
    var configPath: String? = null
    var jdkName: String? = null
}

Обычно они содержат параметры необходимые для среды исполнения: путь к  jdk, maven, gradle и прочее в зависимости от типа внешней системы. В остальном эти настройки близки по структуре к ProjectSettings. Основное отличие в том что это "короткоживущие" настройки, которые не хранятся на диске и получаются непосредственно из ProjectSettings в процессе обращения к внешней системе (подробнее будут ниже).

Редактирование настроек

Для редактирования настроек, необходимо реализовать AbstractExternalProjectSettingsControl. Вот так для примера, выглядит редактирование наших ProjectSettings:

class ProjectSettingsControl(initialSettings: ProjectSettings) :
   AbstractExternalProjectSettingsControl<ProjectSettings>(initialSettings) {
   private var vmOptionsField: JTextField? = null

   override fun validate(settings: ProjectSettings) = true

   override fun resetExtraSettings(isDefaultModuleCreation: Boolean) {
       if (vmOptionsField != null) {
           vmOptionsField!!.text = initialSettings.vmOptions
       }
   }

   override fun fillExtraControls(content: PaintAwarePanel, indentLevel: Int) {
       val vmOptionsLabel = JBLabel("Vm options")
       vmOptionsField = JTextField()
       content.add(vmOptionsLabel, ExternalSystemUiUtil.getLabelConstraints(indentLevel))
       content.add(vmOptionsField, ExternalSystemUiUtil.getLabelConstraints(0))
       content.add(Box.createGlue(), ExternalSystemUiUtil.getFillLineConstraints(indentLevel))
       vmOptionsLabel.setLabelFor(vmOptionsField)
   }

   override fun isExtraSettingModified(): Boolean {
       if (vmOptionsField?.text != (initialSettings.vmOptions ?: "")) {
           return true
       }
       return false
   }

   override fun applyExtraSettings(settings: ProjectSettings) {
       if (vmOptionsField != null) {
           settings.vmOptions = vmOptionsField!!.getText()
       }
   }
}

Тут необходимо реализовать методы:

  • validate - проверка валидности новых настроек;

  • resetExtraSettings - сброс настроек в исходное состояние, если например хотим отменить ещё не сохраненные изменения;

  • fillExtraControls - создание UI компонентов;

  • isExtraSettingsModified - были изменения или нет;

  • applyExtraSettings - сохранение новых настроек.

Для SystemSettings все аналогично, поэтому код приводить тут не буду.

Далее все наши реализации редактирования настроек, необходимо обернуть в AbstractExternalSystemConfigurable, чтобы связать редактирование настроек с нашей билд системой по ее SYSTEM_ID:

class JsonBuildSystemSettingsConfigurable(project: Project) :
   AbstractExternalSystemConfigurable<ProjectSettings, SettingsListener, SystemSettings>(project, SYSTEM_ID) {

   override fun getId() = "reference.settingsdialog.project.jsonBuildSystem"

   override fun newProjectSettings() = ProjectSettings()

   override fun createSystemSettingsControl(settings: SystemSettings) = SystemSettingsControl(settings)

   override fun createProjectSettingsControl(settings: ProjectSettings) = ProjectSettingsControl(settings)
}

Также нужно реализовать метод getId() для программной навигации к настройкам по их идентификатору и методы по созданию UI для каждого типа настроек. И зарегистрировать в plugin.xml.

<projectConfigurable groupId="build.tools" groupWeight="200" id="reference.settingsdialog.project.jsonBuildSystem"
                    instance="ru.rzn.gmyasoedov.jsonbuildsystem.settings.JsonBuildSystemSettingsConfigurable"
                    displayName="JsonBuildSystem"/>

В итоге это будет выглядеть вот так:

Тут у нас есть глобальный переключатель для всех проектов - skip tests. И для каждого проекта (simple-project) в отдельности можно задать vm options. (Настройки просто для примера, и на результат они не влияют).
Тут у нас есть глобальный переключатель для всех проектов - skip tests. И для каждого проекта (simple-project) в отдельности можно задать vm options. (Настройки просто для примера, и на результат они не влияют).

Для настройки skipTest можно реализовать отдельный Action и добавить его вызов на билд-окно.

class SkipTestsAction : ToggleAction() {

   override fun isSelected(e: AnActionEvent): Boolean {
       val project = e.project ?: return false
       return project.getService(SystemSettings::class.java).skipTests
   }

   override fun setSelected(e: AnActionEvent, state: Boolean) {
       val project = e.project ?: return
       val settings = project.getService(SystemSettings::class.java)
       settings.skipTests = !settings.skipTests
   }

   init {
       templatePresentation.icon = AllIcons.RunConfigurations.ShowIgnored
       templatePresentation.text = "Skip Tests"
   }
}

И в plugin.xml:

<actions>
   <action id="JsonBuildSystem.Toolbar.SkipTests" class="ru.rzn.gmyasoedov.jsonbuildsystem.action.SkipTestsAction"/>

   <group id="JsonBuildSystem.View.ActionsToolbar.CenterPanel">
       <separator/>
       <reference id="JsonBuildSystem.Toolbar.SkipTests"/>
       <separator/>
       <add-to-group group-id="ExternalSystemView.ActionsToolbar.CenterPanel" anchor="last"/>
   </group>
</actions>

Будет выглядеть вот так:

Авто импорт проекта

Позволяет настроить интеграцию для автоматического обновления структуры проекта при изменении конфигурационных файлов. Для этого необходимо реализовать ExternalSystemAutoImportAware.

class JsonBuildSystemAutoImportAware : ExternalSystemAutoImportAware {
   override fun getAffectedExternalProjectPath(changedFileOrDirPath: String, project: Project): String? {
       val changedPath = Path.of(changedFileOrDirPath)
       if (changedPath.isDirectory()) return null
       val fileSimpleName = changedPath.fileName.toString()
       if (!JsonBuildSystemUtils.isBuildSystemFileName(fileSimpleName)) return null
       val systemSettings = project.getService(SystemSettings::class.java)
       return systemSettings.getLinkedProjectSettings(changedPath.parent.toString())?.externalProjectPath
   }

   override fun getAffectedExternalProjectFiles(projectPath: String?, project: Project): List<File> {
       projectPath ?: return listOf()
       val systemSettings = project.getService(SystemSettings::class.java)
       val projectSettings = systemSettings.getLinkedProjectSettings(projectPath) ?: return listOf()
       return projectSettings.configPath?.let { listOf(File(it)) } ?: listOf()
   }
}
  • getAffectedExternalProjectPath - служит для проверки того, должно ли конкретное изменение файла/каталога вызывать обновление внешнего проекта.

  • getAffectedExternalProjectFiles - возвращает список всех конфигурационных файлов, которые относятся к проекту, чтобы в случае изменения одного из них показывать иконку для реимпорта проекта.

По умолчанию процесс реимпорта проекта запускается автоматически, при внешних изменениях конфигурационных файлов - например, если изменения пришли из системы контроля версий. В случае ручного изменения билд скриптов, отображается иконка, для вызова реимпорта проекта вручную. Также можно настроить, чтобы процесс автоимпорта был всегда автоматическим при любых изменениях:

Финал. Собираем все вместе

Итого, сейчас мы реализовали следующие конечные точки:

Теперь осталось собрать все вместе и зарегистрировать нашу билд систему в plugin.xml. Для этого нам надо реализовать основную точку расширения для внешних систем - ExternalSystemManager.

class JsonBuildSystemManager : ExternalSystemConfigurableAware, ExternalSystemUiAware, ExternalSystemAutoImportAware,
   ExternalSystemManager<ProjectSettings, SettingsListener, SystemSettings, LocalSettings, ExecutionSettings> {

   private val autoImportAwareDelegate: ExternalSystemAutoImportAware = CachingExternalSystemAutoImportAware(
       JsonBuildSystemAutoImportAware()
   )

   override fun getConfigurable(project: Project) = JsonBuildSystemSettingsConfigurable(project)


   override fun getProjectRepresentationName(targetProjectPath: String, rootProjectPath: String?): String {
       return ExternalSystemApiUtil.getProjectRepresentationName(targetProjectPath, rootProjectPath)
   }

   override fun getExternalProjectConfigDescriptor(): FileChooserDescriptor {
       return FileChooserDescriptorFactory.createSingleFolderDescriptor()
   }

   override fun getProjectIcon() = AllIcons.FileTypes.Json

   override fun getTaskIcon() = AllIcons.Nodes.ConfigFolder


   override fun enhanceRemoteProcessing(parameters: SimpleJavaParameters) =
       throw java.lang.UnsupportedOperationException()

   override fun getSystemId() = SYSTEM_ID

   override fun getSettingsProvider(): Function<Project, SystemSettings> {
       return Function<Project, SystemSettings> { project: Project -> project.getService(SystemSettings::class.java) }
   }

   override fun getLocalSettingsProvider(): Function<Project, LocalSettings> {
       return Function<Project, LocalSettings> { project: Project -> project.getService(LocalSettings::class.java) }
   }

   override fun getExecutionSettingsProvider(): Function<Pair<Project, String>, ExecutionSettings> {
       return Function<Pair<Project, String>, ExecutionSettings> {
           val project = it.first
           val projectPath = it.second
           val systemSettings = project.getService(SystemSettings::class.java)
           val projectSettings = systemSettings.getLinkedProjectSettings(projectPath)
           val executionSettings = ExecutionSettings()
           executionSettings.configPath = projectSettings?.configPath
           executionSettings.jdkName = projectSettings?.jdkName
           projectSettings?.vmOptions
               ?.also { executionSettings.withVmOptions(ParametersListUtil.parse(it, true, true)) }
           executionSettings
       }
   }

   override fun getProjectResolverClass() = JsonBuildSystemProjectResolver::class.java

   override fun getTaskManagerClass() = JsonBuildSystemTaskManager::class.java

   override fun getExternalProjectDescriptor() = BuildFileChooserDescriptor()


   override fun getAffectedExternalProjectPath(changedFileOrDirPath: String, project: Project): String? {
       return autoImportAwareDelegate.getAffectedExternalProjectPath(changedFileOrDirPath, project)
   }

   override fun getAffectedExternalProjectFiles(projectPath: String?, project: Project): List<File> {
       return autoImportAwareDelegate.getAffectedExternalProjectFiles(projectPath, project)
   }
}

<externalSystemManager implementation="ru.rzn.gmyasoedov.jsonbuildsystem.JsonBuildSystemManager"/>

Как можно видеть, менеджер нашей системы реализует:

Тут стоит выделить метод getExecutionSettingsProvider, который принимает на вход проект и его базовый путь, по которому мы находим ProjectSettings и далее конвертируем их в настройки исполнения ExecutionSettings. Которые далее передаются во все сервисы где идет обращения к внешней системе: JsonBuildSystemTaskManager, JsonBuildSystemProjectResolver.

Далее создаем factory-класс для нашего билд-окна:

class JsonBuildSystemToolWindowFactory : AbstractExternalSystemToolWindowFactory(SYSTEM_ID) {
    override fun getSettings(project: Project): AbstractExternalSystemSettings<*, *, *> =
        project.getService(SystemSettings::class.java)
}
<toolWindow id="JsonBuildSystem" anchor="right" icon="AllIcons.FileTypes.Json"
           factoryClass="ru.rzn.gmyasoedov.jsonbuildsystem.view.JsonBuildSystemToolWindowFactory"/>

Теперь все практически готово.

Открытие готовых проектов и создание новых

Остался последний штришок. Нужно научится открывать проекты. Для этого нам понадобится реализация AbstractOpenProjectProvider:

class JsonBuildSystemOpenProjectProvider : AbstractOpenProjectProvider() {
   override val systemId: ProjectSystemId = SYSTEM_ID

   override fun isProjectFile(file: VirtualFile) = JsonBuildSystemUtils.isBuildSystemFile(file)

   override fun linkToExistingProject(projectFile: VirtualFile, project: Project) {
       ExternalProjectsManagerImpl.getInstance(project).setStoreExternally(true)
       val projectSettings = JsonBuildSystemUtils.createProjectSettings(projectFile, project)
       val externalProjectPath = projectSettings.externalProjectPath
       ExternalSystemApiUtil.getSettings(project, SYSTEM_ID).linkProject(projectSettings)
       ExternalProjectsManagerImpl.getInstance(project).runWhenInitialized {
           ExternalSystemUtil.refreshProject(
               externalProjectPath,
               ImportSpecBuilder(project, SYSTEM_ID)
           )
       }
   }
}

Которая отвечает за проверку - принадлежит ли файл нашей билд системе. Далее в методе linkToExistingProject создаем дефолтные настройки нового проекта, где устанваливаем полный путь к конфигурационному файлу проекта (createProjectSettings) и добавляем их к SystemSettings нашей билд системы, вызывая метод linkSettings. И запускаем процесс импорта, который прочитает конфигурационные файлы и создаст проектную модель.

Далее реализуем точку расширения для открытия проектов. Где используем только что созданный ProjectProvider.

class JsonBuildSystemProjectOpenProcessor : ProjectOpenProcessor() {
   private val importProvider = JsonBuildSystemOpenProjectProvider()

   override fun canOpenProject(file: VirtualFile): Boolean = importProvider.canOpenProject(file)

   override fun doOpenProject(
       virtualFile: VirtualFile,
       projectToClose: Project?,
       forceOpenInNewFrame: Boolean
   ): Project? {
       return runBlockingModal(ModalTaskOwner.guess(), "") {
           importProvider.openProject(virtualFile, projectToClose, forceOpenInNewFrame)
       }
   }

   override val name = Constants.SYSTEM_ID.readableName

   override val icon = AllIcons.FileTypes.Json

   override fun canImportProjectAfterwards(): Boolean = true

   override fun importProjectAfterwards(project: Project, file: VirtualFile) {
       importProvider.linkToExistingProject(file, project)
   }
}
<projectOpenProcessor implementation="ru.rzn.gmyasoedov.jsonbuildsystem.wizard.JsonBuildSystemProjectOpenProcessor"/>

Wizard для создания новых проектов и модулей для нашей билд системы можно посмотреть тут. Приводить его здесь я не буду и оставлю на самостоятельный разбор, чтобы не перегружать статью кодом и справочной информаций. Ничего сложного там нет, и создается он по образу и подобию wizard’ов для других билд систем как Maven и Gradle.

Итог

Вот теперь вроде и все. Исходный код плагина выложен на Github. Также там есть примеры простого и мульти-модульного проекта, чтобы более детально разобрать как плагин работает. Надеюсь это будет кому-нибудь полезно.

Комментарии (4)


  1. kavaynya
    11.09.2023 16:48

    Спасибо за статью. Так как я и сам уже писал плагин для idea, мне интересно где вы черпали информацию? В оф. доках весьма мало информации, и в интернете найти что-то сложно.


    1. grisha9 Автор
      11.09.2023 16:48
      +1

      продублирую ссылкой. т.к. "промазал" с ответом, а редактировать уже поздно. https://habr.com/ru/articles/759984/comments/#comment_25953974


  1. grisha9 Автор
    11.09.2023 16:48
    +1

    Ковырял исходники idea) мне это также надо было для написания своего плагина из первой статьи. По этой причине я в конце каждой своей публикации про idea-плагины оставляю ссылку ну репоизиторий idea-community. Собственно тут я и решил задокументировать свой опыт, если кому то еще понадобится.


    1. avbase
      11.09.2023 16:48
      +1

      Спасибо, информации по плагинам действительно выложено немного, тоже писал для Idea (AStudio) для финтеха работа с emv тегами и aid,в основном работа с контекстом (буфер обмена, выделенный текст и т.д), опыт в копилку ...