Bibix (3) IntelliJ 플러그인 개발

bibix를 실제로 사용하기 위해서 개인적으로 가장 중요하다고 생각했던 것이 IDE 플러그인, 특히 내가 몇년째 즐겨 사용하고 있는 IntelliJ용 플러그인을 만드는 것이었다.

IntelliJ은 External System Integration 라고 하는 이름으로 비빅스같은 프로젝트 관리 시스템을 통합하는 방법을 제공해주고 있다. 문서가 간단해서 쉬울줄 알았는데, 실제로 개발을 시작하고 보니 IntelliJ 문서가 부실한 것이었던 것 같다. 그래서 결국 코드를 보거나 이렇게 저렇게 실험을 해서 알아내야 하는 부분이 꽤 많았다. 혹시나 IntelliJ 플러그인을 만들려는 분들께 도움이 될 수 있지 않을까 하는 마음과 훗날 나한테 도움이 될 수 있지 않을까 하는 마음에서 내가 삽질했던 부분과 해결책을 몇가지 기록해본다.

IntelliJ 플러그인 프로젝트 시작

나는 github에 올라와 있는 IntelliJ 플러그인 프로젝트 템플릿을 사용했다. 템플릿을 복사하면 생기는 프로젝트에서 gradle.properties 파일을 열어서 몇가지 수정해주어야 한다. pluginGroup, pluginName을 자기 플러그인에 맞게 고쳐준다. 내 경우에는 platformPluginscom.intellij.java를 기본적으로 추가해주어야 했다.

pluginSinceBuild를 최신 IDE 버전으로 올려주는 편이 좋을 수 있다. 은근히 IDE 플러그인 개발 SDK의 인터페이스가 자주 바뀌는 것 같아서 기왕이면 최신 버전에 맞춰서 하는 편이 편한 것 같다.

코틀린은 별도 설정 없이 바로 사용할 수 있다.

IntelliJ에서 이렇게 생성한 프로젝트를 불러오면 오른쪽의 Gradle 탭에 “Run Configurations > Run IDE for UI Tests”를 사용해서 개발중인 플러그인이 추가된 IDE를 실행할 수 있다. “Run Plugin” 메뉴도 있는데 차이는 잘 모르겠다.

plugin.xml

src/main/resources/META-INF 폴더에 plugin.xml 이라는 XML 파일이 있다. 이 XML 파일에서 플러그인이 IDE의 어떤 부분에 기능을 추가하려고 하는지 정의하게 된다. extensions라는 태그 밑에 새로 추가할 기능의 이름과 내가 구현한 구현체의 클래스 이름을 넣으면 된다.

Extension에서 “추가할 기능의 이름”이라고 했던 것은 실은 다른 플러그인이나 IDE에서 정의한 extension point의 이름이다. 좀 더 복잡한 플러그인을 만든다면 개발중인 플러그인에 extension point를 추가해야할 수도 있다.

External System

External System Integration을 개발한다면, externalSystemManager라는 extension point를 확장해야 한다. 비빅스 플러그인에는 그래서 다음과 같은 코드가 extensions 태그 밑에 추가되었다.

<externalSystemManager implementation="com.giyeok.bibix.intellijplugin.system.BibixManager"/>

이 클래스는 external system의 엔트리 포인트라고 볼 수 있을 것이다. BibixManager 클래스는 ExternalSystemManager라는 인터페이스를 구현하는데, 여기서 요구하는 기능들을 제공하면 얼추 비빅스 플러그인은 구현할 수 있다.

Setting 클래스

ExternalSystemManager 클래스는 무려 5개의 타입 파라메터를 받는다. 이것들은 모두 설정과 관련된 클래스들인데, 만드는게 복잡하진 않다.

BibixSettings, BibixLocalSettings, BibixProjectSettings, BibixExecutionSettings와 BibixSettingsListener 클래스인데, 모두 다 사실상 빈 클래스다. 자신이 필요한 필드를 적절히 추가하면 될 것 같다.

ProjectResolver

BibixManager 클래스의 여러 메소드들 중에서 getProjectResolverClass 메소드는 특히 중요하다. 이 메소드는 ExternalSystemProjectResolver라는 인터페이스를 구현하는 클래스를 반환해야 하고, 그 클래스에는 resolveProjectInfo라는 메소드가 있다. resolveProjectInfo 메소드는 프로젝트에 대한 기본 정보를 받아서 DataNode<ProjectData>를 반환하고, 이 노드 밑에는 프로젝트에 포함되는 모듈, 소스코드의 경로 등의 정보를 담은 노드들이 child로 추가되어야 한다. 이것이 바로 External System Integration 문서에서 정의하는 DataNode이다.

그런데 문제는 이 ProjectData 타입의 데이터 노드 밑에 어떤 타입의 데이터 노드가 올 수 있는지에 대한 잘 정리된 문서가 없다는 사실이다. 이 부분은 경험적으로 실험을 해보면서 얼추 파악했는데 대략 다음과 같이 트리를 구성하면 된다.

ProjectKeys.PROJECT(ProjectData)
+-- ProjectKeys.MODULE(ModuleData)
  +-- ProjectKeys.MODULE(ModuleData)
  +-- ProjectKeys.CONTENT_ROOT(ContentRootData)
  +-- ProjectKeys.MODULE_DEPENDENCY(ModuleDependencyData)
  +-- ProjectKeys.LIBRARY_DEPENDENCY(LibraryDependencyData)
+-- ProjectKeys.LIBRARY(LibraryData)
+-- ProjectKeys.TASK(TaskData)

프로젝트, 모듈, 라이브러리와 같은 개념은 이 문서에 설명되어 있다.

모듈은 다른 모듈을 하위에 포함할 수 있고, 모듈에는 여러 개의 Content root가 올 수 있다. 각 DataNode에는 key와 데이터 타입 T가 지정된다. 위에서 ProjectKeys.**라고 쓴 것이 DataNode의 key이고, 괄호 안에 쓴 것이 데이터 타입이다.

IntelliJ의 데이터 구조는 대체로 mutable한 것 같다. DataNode도 그렇기 때문에, DataNode의 createChild 메소드를 사용하면 parent 노드와 child 노드 양쪽에 모두 데이터가 변경된다. 즉, parent쪽에도 child가 추가된다. ModuleData, ContentRootData 등과 같은 데이터 타입들은 생성자에서 인자를 여러개 받아서 얼핏 보면 immutable 데이터타입인가 싶었지만 아니었다.

예를 들어 content root 데이터같은 경우엔 storePath라는 메소드로 실제 루트를 지정해주어야 한다. 실제 루트는 생성자를 통해서 지정할 수 없었다.

val contentRootData1 = ContentRootData(BibixConstants.SYSTEM_ID, moduleRootPath1)
contentRootData1.storePath(ExternalSystemSourceType.SOURCE, "$moduleRootPath1/main/kotlin")
moduleDataNode1.createChild(ProjectKeys.CONTENT_ROOT, contentRootData1)

여기서는 DataNode의 key가 모두 ProjectKeys에서 정의된 것처럼 보이지만, 꼭 이 클래스가 아니라 다른 곳에서 정의된 key도 사용할 수 있다.

SDK

resolveProjectInfo에서 프로젝트에 대한 데이터를 만들고나면, 왼쪽의 Project 탭에 프로젝트 구조가 적당히 나타난다. 하지만 Project Structure 창을 열어보면 프로젝트와 모듈 모두 SDK가 지정되지 않았다고 뜬다. 이 부분을 해결하느라 고생을 좀 했다.

ProjectSdkData.KEY라는 키가 있고 ProjectSdkData라는 데이터 타입이 있어서, ProjectData 노드 밑에 이런 노드를 추가하면 되지 않을까.. 라고 생각했는데 그렇게 되지 않았다. ProjectRootManager라는 클래스를 이용해서 sdk를 설정해야 하는데, 문제는 resolveProjectInfo함수가 실행되는 시점에는 ProjectRootManager가 요구하는 Project 클래스 인스턴스가 아직 만들어지기 전이라는 점이다.

이 문제는 DataService를 사용해서 해결할 수 있다. DataService는 (내가 제대로 이해한 것이라면) DataNode가 새로 만들어지면, 해당 DataNode에 대해 실행되는 코드이다. 예를 들어, 비빅스 플러그인에는 BibixProjectDataSerivce라는 클래스가 있고, 이 클래스는 AbstractProjectDataService<ProjectData, Project>라는 가상 클래스를 상속받는다. 그 안에는 importDatapostProcess같은 메소드가 있는데, 이 메소드들은 ProjectData를 지정하는 DataNode가 생성되면 실행된다. DataService를 만들려면 ProjectDataService 인터페이스나 그 인터페이스를 상속받은 AbstractProjectDataService 클래스를 상속받는 클래스를 구현하고, plugin.xml 파일에 externalProjectDataService로 등록해주어야 한다.

그래서 BibixProjectDataSerivce.postProcess 메소드에서 ProjectRootManager.getInstance(project).projectSdk = sdk 라고 하는 식으로 프로젝트 SDK를 지정해주었다. 여기서 sdk를 찾을 때는 여기서 lookupSdk라고 하는 유틸리티 함수를 사용했다. 다만 여기서 한가지 이상하다고 생각했던 것은, ProjectJdkTable.getInstance().allJdks를 사용하면 현재 프로젝트에서 사용 가능한 JDK 목록을 얻을 수 있는데, lookupSdk함수를 호출하면 이 값이 변경된다는 것이다. “lookup”이라는 단어는 side effect가 없어야될 것 같은데 lookup하는 과정에서 뭔가가 변경되는 모양이다.

여기서 한가지 주의해야 할 것이 있다. project의 값을 변경하려면 WriteCommandAction.runWriteCommandAction(project)라는 기능을 이용해서 해야지 그렇지 않으면 경고가 뜬다는 점이다.

(TBD) tool window

shouldBeAvailable

plugin.xml에 toolWindow로 등록 필요

(TBD) task

resolveProjectInfo할 때 만든 task들은 tool window에 표시됨. 더블클릭하면 실행할 수 있는데, 실행하면 BibixManager에 getTaskManagerClass에서 반환된 클래스(비빅스의 경우 BibixTaskManager) 클래스의 executeTasks 메소드가 실행된다. 이 메소드는 IDE를 블락하지 않기 때문에 좀 오래걸리는 작업을 해도 큰 문제는 없을 것 같다.

이 때, plugin.xml에 configurationType 등록하지 않으면 task가 실행되지 않는다. executor를 얻을 수 없다는 류의 오류가 나오는데 사실 잘 이해되진 않지만 그냥 AbstractExternalSystemTaskConfigurationType 클래스를 상속받는 빈 클래스를 만들어서 plugin.xml에 configurationType으로 등록해주면 해결된다.

(TBD) ProjectOpenProcessor

canOpenProject

External System Integration 문서에서는 ImportBuilder와 ImportProvider를 만들어야 한다고 하는데, 실제로 내 경우엔 이 두 클래스는 전혀 만들 필요가 없었다. ExternalSystemManager 클래스를 만들고 거기서 요구하는 기능들만 잘 제공해주면 얼추 원하는 건 다 얻어낼 수 있었다.

ImportProvider, ProjectOpenProcessor 등과 ExternalSystemManager의 관계를 잘 모르겠지만.. ProjectOpenProcessor의 canOpenProject 함수는 중요함. 이게 있어야 디렉토리 트리에서 열 수 있는 폴더 옆에 아이콘이 달라짐.

plugin project clean

비빅스 프로젝트는 폴더에 build.bbx 파일이 있으면 그 폴더는 비빅스 프로젝트라고 간주한다. IntelliJ는 IntelliJ 프로젝트에 .idea라는 폴더가 있는지를 확인한다.

.idea 폴더가 없이 build.bbx 파일만 있으면 앞서 설명한 과정들을 거쳐(ProjectData의 DataNode 등을 생성함으로써) IntelliJ 프로젝트 구조로 변환하게 되고, 변환된 내용은 .idea 폴더에 저장된다.

사실 지금도 정확히 이 과정이 어떻게 동작하는지는 모르겠지만, .idea 폴더 없이 build.bbx 파일만 있는 프로젝트를 불러온 다음, plugin project를 clean하고나서 다시 해당 프로젝트를 읽으면 IntelliJ 프로젝트로 ProjectData나 ModuleData는 잘 설정되어 있지만 bibix와의 연동은 끊어져 있었다. 물론 그 상황에서 .idea 폴더를 지우고 다시 build.bbx 파일을 읽어오면 잘 읽혔다.

그런데 build.bbx 파일만 있는 프로젝트를 불러와서 .idea 폴더가 만들어지고, 그 다음 plugin project를 clean하지 “않고” 그 바이너리 그대로 실행시켜서 해당 비빅스 프로젝트를 불러오면 bibix와 잘 연동되는 IntelliJ 프로젝트가 열렸다. 내 상식상으로는 IntelliJ 플러그인 코드의 내용이 바뀌지 않은 상태라면 같은 프로젝트를 읽었을 때 같은 결과가 나와야하지 않나 싶은데.. 여튼 이해할 수 없는 현상이 있었고, 이 문제때문에 또 한 이틀정도 속을 썩였다.

실제 bibix와의 연동

처음에는 bibix를 그냥 라이브러리 형태로 묶어서 플러그인에 통째로 넣을 생각이었다. 그런데 어떤 이유인지 모르겠지만 bibix jar를 플러그인 코드에서 사용할 수가 없었다. 일반적인 라이브러리들은 플러그인 템플릿을 복사하면서 생긴 build.gradle.kts 파일에 최상단 레벨에 dependencies 섹션을 추가하고 다른 gradle 프로젝트에서 하듯 디펜던시를 넣어주면 잘 동작했는데, 유독 bibix jar만 동작하질 않았다. 이 문제도 한 이틀정도 해결해보려고 했는데 아직 해결책을 찾지 못했다.

대신, 개발을 하다보니 bibix를 플러그인 안에 통째로 넣는게 옳은가 라는 의구심이 들었다. gradle등의 경우엔 별도의 gradle 대몬 인스턴스를 띄우고 IntelliJ의 gradle 플러그인이 통신하면서 동작하는데, 처음에는 과하게 복잡하다고 생각했던 이 방식이 실은 옳은 방식인 것 같아서 지금은 방향을 선회한 상태다.

결론(2023/3/21 업데이트)

남은 문제

참고할 문서와 코드

기본적으로는 IntelliJ Plugin SDK 문서가 가장 중요한 자료이다. 하지만 생각보다 문서화가 잘 되어있지는 않은 것 같았다. 포럼같은 것이 있긴 한데 내 경우엔 별 도움이 되지는 않았다.

다행히 IntelliJ는 커뮤니티 버전을 오픈소스로 제공하고 있기 때문에 참고할 코드는 꽤 많다. 나는 처음에 커뮤니티 버전에 포함된 gradle 플러그인 코드를 참고하려고 했다. 그런데 gradle 플러그인은 너무 복잡해서 실질적으로 큰 도움이 되진 않았다. 되려 비슷한 기능을 하는 다른 플러그인을 찾아서 그 코드를 보는 편이 도움이 되는 경우가 많다. 나는 IntelliJ Marketplace에서 빌드 시스템의 플러그인 몇 개를 둘러보다 kobalt라고 하는 빌드 시스템의 IntelliJ 플러그인을 찾아서 코드를 참고했다.

이 페이지에서 오류나 문제점을 발견하시면 이메일로 알려주시면 감사하겠습니다.
뒤로