본문 바로가기
JetBrains Plugin

IntelliJ PSI Element & FileViewProvider 이해와 활용

by 오근성 2025. 6. 17.
728x90

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 트리 만들기 등 플러그인 확장에 초점을 맞출 수 있습니다.

728x90

댓글