IntelliJ PSI Element & FileViewProvider 이해와 활용
📌 개념 요약: PSI 요소(PSI Element) 란?
- IntelliJ 플랫폼은 파일을 읽을 때, 단순 문자열이 아니라 구조화된 트리 형태(PSI Tree) 로 이해합니다.
- 이 트리의 노드 하나하나가 바로 PsiElement 입니다.
- 예를 들어 Java 코드에서 int count = 0; 라는 한 줄에는 PsiType, PsiIdentifier, PsiLiteralExpression 등의 여러 PSI 요소가 존재합니다.
📌 PSI 관련 주요 API 정리
PSI 요소를 다루기 위해 사용할 수 있는 주요 API들을 먼저 도구처럼 정리해두면, 이후 어떤 전략을 적용할지 결정하는 데 도움이 됩니다.
카테고리 API / 클래스 설명
카테고리 | API/클래스 | 설명 |
---|---|---|
획득 | AnActionEvent.getData(CommonDataKeys.PSI_ELEMENT) | 현재 커서 위치 또는 UI 이벤트로부터 PSI Element 획득 |
psiFile.findElementAt(offset) | 오프셋 위치에 해당하는 최하위 PSI Element 가져오기 | |
PsiTreeUtil.getParentOfType(element, Class |
특정 타입의 부모 PSI 요소를 탐색 (상위로 이동) | |
PsiTreeUtil.findChildrenOfType(element, Class |
특정 타입의 자식 PSI 요소들을 모두 탐색 (하위로 이동) | |
PsiElement.getContainingFile() | 해당 요소가 속한 PSI 파일 반환 | |
psiElement.getTextOffset() | PSI 요소가 시작되는 오프셋 (에디터 상 위치) 반환 | |
psiElement.getTextRange() | PSI 요소의 전체 범위 반환 (시작/끝 offset 포함) | |
트리 순회 | PsiRecursiveElementWalkingVisitor | 트리 전체를 순회하며 원하는 PSI 요소를 탐색 (Top-Down 방식) |
JavaRecursiveElementVisitor | Java PSI 요소 전용 Visitor (메서드/클래스 단위로 편리) | |
KtTreeVisitorVoid / KtVisitorVoid | Kotlin PSI 순회용 Visitor (Kotlin 전용) | |
참조(Reference) | psiElement.getReference() | 해당 PSI 요소가 참조(usage)일 경우 Reference 객체 획득 |
PsiReference.resolve() | 참조의 대상(선언 위치) 획득 | |
PsiElement.getReferences() | 복수의 참조를 반환할 수도 있음 (다중 참조 가능 요소에 대해) | |
PsiReference.getElement() | 참조가 발생한 PSI 요소 (usage 위치) 반환 | |
ReferencesSearch.search(declarationElement) | 선언된 PSI 요소로부터 모든 usage 위치 탐색 | |
타입 확인 및 캐스팅 | instanceof / Kotlin의 is 연산자 | PSI Element의 실제 타입 검사 (예: element is PsiMethod) |
Java: PsiMethod, PsiClass, PsiField, PsiIdentifier 등 | 언어별 PSI 타입 체크 및 다운캐스팅 | |
Kotlin: KtNamedFunction, KtClass, KtProperty 등 | Kotlin 언어 전용 PSI 클래스들 | |
변경 작업 | WriteCommandAction.runWriteCommandAction(...) | PSI Element를 수정하는 경우 반드시 이 안에서 실행해야 함 |
psiElement.delete() / replace() / setName() 등 | PSI Element 삭제, 교체, 이름 변경 등 가능 |
각각의 PSI 관련 API는 실제 플러그인 개발이나 구조 분석 시 핵심 도구로 활용됩니다.
이 문서에서는 앞서 요약한 표에 포함된 주요 API들을 하나의 단락씩 정리하며, 예제와 활용처를 함께 제공합니다.
🔹 AnActionEvent.getData(CommonDataKeys.PSI_ELEMENT)
현재 커서 위치 또는 선택된 요소의 PSI Element를 가져올 때 사용합니다.
override fun actionPerformed(e: AnActionEvent) {
val psiElement = e.getData(CommonDataKeys.PSI_ELEMENT)
if (psiElement != null) {
println("선택된 PSI Element: ${psiElement.text}")
}
}
- 흔히
Editor
,Project
,PsiFile
등과 함께 사용되어, Action 수행 시 컨텍스트 데이터를 추출합니다.
🔹 psiFile.findElementAt(offset)
특정 오프셋(문자 위치)에서 가장 하위 레벨의 PSI Element를 가져옵니다.
val elementAtCaret = psiFile.findElementAt(editor.caretModel.offset)
- 해당 위치에 어떤 코드 요소가 있는지 정밀하게 파악할 때 유용합니다.
- 이후
PsiTreeUtil.getParentOfType(...)
과 조합하여 상위 구조 탐색에 활용됩니다.
🔹 PsiTreeUtil.getParentOfType(element, Class)
현재 요소 기준으로 위로 올라가며, 원하는 타입의 상위 PSI 요소를 반환합니다.
val method = PsiTreeUtil.getParentOfType(psiElement, PsiMethod::class.java)
- 변수, 표현식 등에서 출발해 해당 메서드나 클래스 정보를 얻을 수 있습니다.
- 편집 중인 코드가 어느 구조에 포함돼 있는지 판단할 때 매우 유용합니다.
🔹 PsiTreeUtil.findChildrenOfType(element, Class)
지정된 PSI 요소 내에서 특정 타입의 자식 PSI 요소들을 모두 수집합니다.
val allReturns = PsiTreeUtil.findChildrenOfType(method, PsiReturnStatement::class.java)
- 메서드 내의 return 문, 변수 선언, 주석 등 원하는 타입을 한 번에 가져올 수 있습니다.
🔹 PsiElement.getContainingFile()
현재 PSI Element가 어떤 파일에 속해 있는지를 반환합니다.
val file = psiElement.containingFile
- 추출한 PSI Element가 실제로 어떤 파일 소스의 일부인지를 확인하거나,
- 파일의 ViewProvider나 언어 구조에 접근할 때 필요합니다.
🔹 psiElement.getTextOffset() / getTextRange()
해당 PSI 요소가 시작되는 위치(offset) 또는 전체 텍스트 범위를 반환합니다.
val start = psiElement.textOffset
val range = psiElement.textRange
- 오류 마커(Highlight), 네비게이션, 코드 리포트 등 UI 연동 시 위치 정보 활용에 필수입니다.
🔹 PsiRecursiveElementWalkingVisitor
PSI 트리를 전체 순회할 수 있는 범용 Visitor 클래스입니다.
psiFile.accept(object : PsiRecursiveElementWalkingVisitor() {
override fun visitElement(element: PsiElement) {
super.visitElement(element)
println("방문 중: ${element.javaClass.simpleName}")
}
})
- 구조 전체를 순회하며 일괄 처리할 때 유용합니다. 언어 독립적으로 사용할 수 있습니다.
🔹 JavaRecursiveElementVisitor
Java 전용 PSI 트리 순회용 Visitor로,
visitMethod
,visitField
등 override 지점이 명확합니다.
psiFile.accept(object : JavaRecursiveElementVisitor() {
override fun visitMethod(method: PsiMethod) {
super.visitMethod(method)
println("메서드 이름: ${method.name}")
}
})
- Java PSI에 특화되어 있어, 구조 탐색이 매우 간결해집니다.
🔹 psiElement.getReference(), getReferences()
PSI 요소가 참조(usage)일 경우 참조 객체를 획득할 수 있습니다.
val reference = psiElement.reference
val resolved = reference?.resolve()
- 다중 참조가 가능한 경우는
getReferences()
를 사용하며, 특정 위치에서 어떤 선언을 참조하는지 추적할 수 있습니다.
🔹 PsiReference.getElement(), resolve()
getElement()
는 참조가 발생한 위치,resolve()
는 참조의 대상(선언)을 나타냅니다.
val source = reference.getElement()
val target = reference.resolve()
- 이 두 메서드의 의미 차이를 확실히 이해하면, 코드 분석 및 리팩토링 도구 작성에 큰 도움이 됩니다.
🔹 ReferencesSearch.search(declarationElement)
선언된 PSI 요소가 어디서 사용되었는지를 탐색합니다.
for (ref in ReferencesSearch.search(method)) {
println("사용 위치: ${ref.element.text}")
}
- 선언 → 사용 흐름의 역추적 용도로 사용되며, 코드 영향도 분석에 매우 중요합니다.
🔹 PsiPolyVariantReference.multiResolve()
JavaScript, Kotlin 등 동적 또는 다중 해석이 가능한 언어에서 참조 후보를 모두 반환합니다.
val results = (ref as PsiPolyVariantReference).multiResolve(false)
- 오버로드된 메서드 호출, 동적 타입 추론 등 다수의 후보가 존재할 수 있는 경우 유용합니다.
🔹 WriteCommandAction.runWriteCommandAction
PSI를 수정할 때는 반드시 이 블록 내에서 실행해야 합니다.
WriteCommandAction.runWriteCommandAction(project) {
element.setName("newName")
}
- 이름 변경, 삭제, 교체 등은 모두 이 안에서 실행되어야 안전합니다.
- 그렇지 않으면 IDE에서 IllegalWriteException이 발생할 수 있습니다.
🔹 psiElement.delete(), replace(), setName()
PSI Element를 제거하거나 다른 Element로 교체하거나 이름을 변경할 수 있습니다.
val newExpr = JavaPsiFacade.getElementFactory(project)
.createExpressionFromText("0", null)
WriteCommandAction.runWriteCommandAction(project) {
oldExpr.replace(newExpr)
}
- 대부분의 수정은
PsiElement
기반의replace
,delete
,setName
으로 수행됩니다.
이 문서는 PSI 요소를 처음 다루는 개발자 또는 실무에서 깊이 있는 플러그인 기능을 개발하는 팀에게 실질적인 API 기반 활용 레퍼런스로 활용될 수 있습니다.
📌 PSI 탐색 전략
1. Top-Down 방식
- 루트부터 내려가며 원하는 노드를 찾는 방식입니다.
- PsiRecursiveElementVisitor 또는 언어별 Visitor (JavaRecursiveElementVisitor) 를 사용합니다.
psiFile.accept(new JavaRecursiveElementVisitor() {
@Override
public void visitLocalVariable(@NotNull PsiLocalVariable variable) {
super.visitLocalVariable(variable);
System.out.println("Found variable: " + variable.getName());
}
});
2. Bottom-Up 방식
- 특정 위치(offset)에서 시작하여 상위 노드를 추적합니다.
- PsiFile.findElementAt(offset) → PsiTreeUtil.getParentOfType() 사용
PsiElement element = psiFile.findElementAt(offset);
PsiMethod method = PsiTreeUtil.getParentOfType(element, PsiMethod.class);
PsiClass clazz = method.getContainingClass();
3. 참조 기반 방식
- 참조(usage) → 선언(declaration)으로 이동하거나 그 반대를 수행
- PsiReference.resolve() 로 선언 위치 추적
- ReferencesSearch.search() 로 참조 위치 추적
PsiReference ref = element.getReference();
PsiElement resolved = ref.resolve();
📦 FileViewProvider란?
- 하나의 파일이 여러 언어로 구성된 경우(예: JSPX) 각 언어별 PSI 트리를 관리하는 역할
- 하나의 VirtualFile 및 Document 에 대응하며, 다양한 언어의 PsiFile 을 제공합니다.
✅ 사용 예시
val viewProvider = psiFile.viewProvider
val xmlPsi = viewProvider.getPsi(XMLLanguage.INSTANCE)
🧩 PSI Reference란?
- 참조(Reference)는 선언이 아닌, 사용 위치를 의미함
- PsiElement.getReferences() 로 해당 요소가 가진 참조 목록을 얻을 수 있음
- PsiReference.resolve() 로 참조가 가리키는 선언 위치 파악
PsiElement usage = ...;
PsiReference ref = usage.getReference();
PsiElement declaration = ref.resolve();
- PsiReference.getElement() → 참조의 출발지 (usage)
- PsiReference.resolve() → 참조의 대상지 (declaration)
🌱 PSI Reference 확장 방법
- 문자열, 주석, XML 속성 등 참조가 없는 PSI 요소에도 의미를 부여하여 이동 가능하게 만듦
- PsiReferenceContributor 구현 후 plugin.xml 에 등록
<extensions defaultExtensionNs="com.intellij">
<psi.referenceContributor language="JAVA" implementationClass="com.example.MyReferenceContributor"/>
</extensions>
- 내부에서 registerReferenceProvider() 호출 시 ElementPattern 으로 위치 지정 가능
✅ Reference 관련 특수 케이스
케이스 설명
케이스 | 설명 |
---|---|
Soft Reference | 못 찾더라도 오류 처리하지 않음 (isSoft() == true) |
Polyvariant Reference | 여러 개의 후보를 반환함 → multiResolve() 사용 |
ResolveResult[] results = ((PsiPolyVariantReference)ref).multiResolve(false);
🔍 Declaration → Usage 찾기 (반대 방향)
- ReferencesSearch.search(declarationElement) 사용
for (PsiReference reference : ReferencesSearch.search(method)) {
PsiElement usage = reference.getElement();
// 참조 위치 처리
}
🌐 고급 예제: Usage 기반으로 PSI Element 재귀 추적하기
다음은 선언된 PSI 요소로부터 ReferencesSearch 를 사용해 사용 위치(usage)를 모두 찾고, 그 usage 들로부터 다시 참조 대상(선언)을 재귀적으로 탐색하는 예시입니다.
void collectRecursiveUsages(PsiElement root, Set<PsiElement> visited) {
if (!visited.add(root)) return; // 이미 방문한 노드는 skip
for (PsiReference reference : ReferencesSearch.search(root)) {
PsiElement usage = reference.getElement();
System.out.println("Found usage at offset: " + usage.getTextOffset());
// usage로부터 다시 참조 대상 탐색
for (PsiReference usageRef : usage.getReferences()) {
PsiElement next = usageRef.resolve();
if (next != null) {
collectRecursiveUsages(next, visited);
}
}
}
}
이 방식은 다음과 같은 상황에서 활용될 수 있습니다:
- 복잡한 참조 체인을 따라가며 영향도를 분석할 때
- 순환 참조 여부를 검사하거나
- 특정 선언이 간접적으로 영향을 주는 모든 경로를 추적할 때
이후 단계에서는 PsiReferenceContributor, Reference Providers, custom language PSI 트리 만들기 등 플러그인 확장에 초점을 맞출 수 있습니다.
'JetBrains Plugin' 카테고리의 다른 글
IntelliJ 에서 PSI 변경하기 (1) | 2025.06.17 |
---|---|
IntelliJ PSI (Program Structure Interface) 개요 및 실전 예제 (0) | 2025.06.17 |
댓글