공식적으로 지원하지 않는다면,
직접 만들어 사용해보는건 어떨까요?
안녕하세요, 픽스커뮤니케이션에서 일한지 벌써 8개월이 다되어가는 “라이언” 이라고 합니다!
연말에 휴가를 길게 내서, 심심한 찰나에 PhpStorm에 직접 템플릿 언어 플러그인을 개발하기로 하였습니다.
짝짝!!
저희 회사는 php를 주력으로 개발하고 있습니다.
관리자 페이지 화면단은, TemplateUnderbar (템플릿 엔진) 라는 기술을 사용하여, 개발하고 있습니다.
하지만, 이게 html 파일 위에 해당 문법을 작성하게 되는거라서, 오류가 날 때도 있고, 본인이 제대로 된 문법을 사용했는지 알 길이 어렵습니다.
저도, 처음에 해당 템플릿 언어를 작성할 때, 보조해주는 도구가 없으니까, 알 수 없는 실수들을 많이 경험하였습니다.
Jetbrains IDE 언어 파싱 구조
Jetbrains IDE는 Java 기반의 언어로 만들어진 프로그램 입니다. WebStorm이나, PhpStorm, IntelliJ 모두 마찬가지 입니다. Java기반 이므로, 당연히 일부는 Kotlin으로도 구성되어 있습니다.
Lexer (.flex)
•
우선적으로 언어 파싱의 시작점이 되는 부분은 바로 Lexer라고 불리는 친구입니다.
•
전체 코드덩어리를 직접 지정한 정규식 규칙에 따라서, Token 단위로 분리하는 작업을 담당합니다.
•
.flex 라는 파일을 통해서, 파싱할 범위를 직접 지정하도록 합니다.
•
추후에 .flex 언어를 JFlex라는 도구를 사용하여, Java코드로 변환시켜줍니다.
Parser (.bnf)
•
Lexer를 통해 토큰화된 언어 조각에, 특정 문법을 부여하는 역할을 합니다.
•
if문이 있다면, 어떤 형태로 동작할 것이며, loop문이 있다면.. 등 이렇게 언어에 대한 틀을 잡아주는 역할을 합니다.
•
Lexer를 통해서, 이미 토큰화가 되었기에, 그 외의 데이터들은 신경쓰지 않아도 됩니다.
•
추후에 .bnf 언어를 Java코드로 변환시킬 수 있습니다.
코드단
•
Parser 영역까지 거치고 나면, 실제 우리는 코드 단에서 토큰을 제어할 수 있게 됩니다.
•
이 때, AST(Abstract Syntax Tree) 라는 구조 안에서, 토큰을 제어하게 됩니다.
•
왜냐하면, 코드단에서 주로 할 것은, if문이 제대로 닫혔는지, 다음 나란한 노드는 뭔지, 자식에는 뭐가 있는지 이런 것들을 직접 탐색해야 하기 때문입니다.
•
각각의 노드들은 PsiElement라고 칭합니다. 우리는 코드 내에서 문법의 어떤 동작을 다룰려고 할 때 마다, PsiElement를 통해서 항상 다루게 됩니다.
•
PsiElement는 하나의 노드를 의미하고, 그 하위에 근본적으로 IElementType을 가지고 있습니다. 이는 하나 하나의 토큰 타입을 의미합니다.
◦
우리가 실제로 토큰을 구분 지을 때, { 형태, } 형태 이렇게 구분 지을 수 있는데요, 이거 하나 하나를 IElementType으로 부른다고 생각하면 됩니다. 그거를 PsiElement가 감싸고 있는 형태입니다.
결론
•
Lexer (.flex) → Parser (.bnf) → Code
•
위와 같은 형태로 진행됩니다.
IntelliJ를 통한 문법 지원 개발 도구
Jetbrains IDE는 Java기반으로 만들어진 프로그램 이므로, 당연히 플러그인을 개발하기 위해서는, PhpStorm이 아니라, IntelliJ 를 사용하여 개발을 진행하여야 합니다.
놀랍게도, 플러그인 개발은 CE(Community Edition)으로 충분히 개발이 가능합니다.
인텔리제이에서 언어 문법 플러그인을 개발하기 위해서는
총 3개의 플러그인의 설치가 필요합니다.
•
Plugin DevKit
JetBrains에서 기본적으로 제공하는 플러그인 개발을 위한 플러그인 입니다.
필수적으로 설치가 필요합니다. 해당 플러그인을 설치하면, 플러그인 프로젝트를 생성할 수 있습니다.
•
Grammar-Kit
문법 관련 플러그인을 개발하기 위해서 반드시 필요한 플러그인 입니다.
다양한 도구 및 문법을 위한 클래스를 제공합니다.
.bnf, .flex 파일을 자바코드로 변환시키는 도구도 바로 여기에 들어있습니다.
•
PsiViewer
필수는 아니지만, 올바르게 Token화가 되어, AST가 올바르게 구성되었는지 확인하기 위해서는 필요한 도구 입니다. 해당 플러그인을 통해, 현재 IF문이 어디까지 진행되었고, 토큰이 뭐가 생성되었고, 이런 부분을 Tree 구조로 쉽게 확인할 수 있습니다. 만약 해당 도구가 없다면, AST가 제대로 생성되었는지 조차 확인하지 못할 것입니다.
그러면, 어디서 뭐가 잘못되었는지 확인도 당연히 안되겠죠
Plugin.xml
플러그인의 정보를 담아두는 파일입니다.
플러그인에 어떤 의존성을 추가할지, 해당 플러그인은 어떤 기능을 사용할지 정의하는 파일입니다.
뿐만 아니라, 플러그인에 대한 설명, 업데이트 로그도 작성할 수 있습니다.
build.gradle.kts
Plugin Devkit 플러그인을 사용하여 프로젝트를 생성하였다면,
코틀린 형태의 Gradle 빌드가 가능한 프로젝트 형태로 생성되게 됩니다.
즉, 이 말은 JetBrains IDE의 플러그인 개발은 코틀린 언어에 최적화 되어있음을 나타냅니다.
여기에서는 플러그인 버전, IDE 타겟팅, 소스 코드 경로 관리를 진행합니다.
기반 코드 생성
Language와, TokenType, ElementType, File 에 대한 클래스를 생성하고 정의합니다.
이는 언어 문법 플러그인을 만드는데 가장 기반이 됩니다. 단순한 정의지만, Language에 대한 기준을 정의합니다. 특이나, HTML 문법 위에 기생하는 Template 언어는 이런 Language에 대한 정의가 매우 중요합니다. 왜냐하면, Language가 여러개가 나오기 때문이다.
HTML언어면 사실 HTML, XML, JavaSciprt, CSS 총 4개의 언어가 관여되어 있다고 볼 수 있습니다. 우리는 이러한 언어 기반 위에, 새로운 Language를 선언하고, 우리가 만든 토큰과 문법은 모두 이를 참조하도록 해야 합니다.
Parser 1차 생성
실제로 코드 동작 흐름은 Lexer → Parser → 코드 순으로 흘러가지만,
개발은 우선 Parser부터 시작해야 합니다.
왜냐하면, Parser 내부에는 Token을 정의할 수 있기 때문인데요, Parser에 작성한 Token을 기준으로, Grammer-Kit로 java 파일을 생성할 때, Token이 담긴 인터페이스 파일을 생성해주기 때문입니다.
생성된 인터페이스 파일을 Lexer (.flex) 에서 참조해야 하므로, 우선적으로 Parser가 우선시 필요됩니다.
.bnf는 resources 폴더 하위에 임시로 만들면 됩니다.
실제로 동작하는 부분은 아니고,
Grammar-Kit을 통해서, java코드로 생성할 수 있습니다.
.bnf 파일을 우클릭하여 “Generate Parser Code” 를 누르게 되면, gen 폴더에 새롭게 파일들이 생성됩니다.
여기서 주의할 점으로는 .bnf 에는 tokens를 통해, 미리 사용할 토큰을 정의하게 되는데, 여기에 정의되어 있다고 하더라도 하단 문법에 사용되지 않으면, 최적화 코드에 의해서, java 토큰 타입으로 생성되지 않음을 유의 해야 합니다. 초안 문법을 미리 구상하여, 간단하게 라도 해당 토큰들을 포함시켜 작성해야 합니다.
{? exVar.text}
BNF
복사
if문이 위와 같다고 하면,
if문 ⇒ LBRACE IF_TAG expressions RBRACE
이런 형태로 작성될 수 있습니다. 해당 문법이 바로 IF문 인 것이죠, 이런 간단한 초안 형태를 미리 작성이라도 해야, java 객체에 생성될 수 있습니다.
gen 폴더를 프로젝트 소스로 인식하기
.bnf 파일을 java코드로 변환하게 되면, 프로젝트 내부에서는 해당 코드를 인식하지 못합니다.
왜냐하면, kotlin 디렉토리와 나란하게, 프로젝트 상위에 코드가 gen폴더 하위에 만들어 지므로,
인식하지 못합니다.
build.gradle.kts 에서
아래와 같은 내용을 추가하면, 정상적으로 gen 하위에도 같은 프로젝트 경로로 코드를 인식하게 됩니다.
sourceSets {
main {
java {
srcDirs("src/main/gen") // gen 폴더를 소스 경로로 추가
}
}
}
BNF
복사
Lexer 생성
.flex 파일을 만드는데, resource 폴더 하위에 임시로 만들어줍니다.
.flex도 Grammar-Kit에 있는 도구를 통해서, .bnf 처럼 Java 코드를 생성해주거든요.
.flex 파일을 작성할 때는, 문법을 작성하고, 특정 토큰에 들어오면, 지정된 타입을 리턴해주면 되는 간단한 형태입니다.
전체 코드를 기준으로, 어떤 정규식을 사용해야, 특정 토큰을 얻을 수 있는지 작성하도록 합니다.
이 때, 템플릿 언어에서 가장 중요한 것은, 바로 OUTER_TEXT를 명확히 구분하는 것입니다.
템플릿 언어의 범주에 포함시키고 싶지 않은 친구들은, Lexer에서 반드시 확실히 OUTER_TEXT로 리턴하도록 명시해야 합니다.
.flex 파일을 다 구성하였다면, 파일을 우클릭하여, “Run JFlex Generator”를 클릭해주세요, 이 때 처음 실행한다면, 파일탐색기가 뜨게 되는데, 현재 프로젝트를 지정하도록 합니다.
Template 언어에 대한 개조
Grammar-Kit은 일반적으로 HTML에 기생하는 템플릿 언어가 아니고,
단독 언어를 작성하는데를 기반으로 가이드를 제공하고 있습니다. 공식문서에도 튜토리얼이 있지만, 템플릿 언어를 개발하는 가이드는 제공해주지 않습니다.
그래서, 특정언어에 기생하는 템플릿 언어를 개발하기 위해서는, 별도의 처리가 필요하게 됩니다.
이 말은 무엇을 의미하냐면, 기본적으로 제공하는 기능 말고, 약간 우회하는 방향으로 가야한다는 말과도 같습니다.
실제로 템플릿 언어 문법 플러그인을 개발해보았다면, 알 수 있겠지만, 생각보다 지원이 안되는 부분이 있습니다. 단일 언어 지원 플러그인에서는 잘 돌아갔던 코드라도, 템플릿 언어로 지원하려고 하는 순간 안되는 부분들이 꽤나 발생합니다.
저도 이 부분에서, 3일간 시간을 날렸습니다. 공식문서에는 해당 사항을 표기조차 하지 않았습니다.
감사하게도, 어떤 한 외국인 분이 가이드를 올려준 부분이 있어, 해당 내용을 참고하여, 저도 성공적으로 HTML에 기생하는 문법 플러그인을 만들 수 있게 되었습니다.
먼저 FileViewProvider가 필요합니다.
Provider는 특정 조건에 따라서, 각각 별개의 동작을 하는 친구를 의미합니다.
여기서의 File은 HTML 문법이냐, Template Underbar의 문법 이냐를 구분하는 것이죠
•
관련 내용은 위의 커뮤니티 게시글을 참고하시면 됩니다.
•
위의 게시글에서 중요한 부분은 OUTER_TEXT(HTML_TEXT)에 해당하는 부분은 반드시 TemplateElementDataType 으로 치환해야 한다는 점입니다.
/**
* 외부/내부 데이터를 명확히 구분하기 위한 템플릿 데이터
*/
class TplTemplateDataElementType : TemplateDataElementType(
"TPL_TEMPLATE_DATA",
TplLanguage,
TEMPLATE_HTML_TEXT, // 템플릿 언어의 HTML_TEXT -> OUTER_TEXT와 연결됨.
OUTER_ELEMENT_TYPE // 외부 엘리먼트를 담아두는 타입을 정의함.
) {
companion object {
// HTML 영역을 담아두는 엘레먼트
val TEMPLATE_HTML_TEXT: IElementType = TplElementType("TPL_TEMPLATE_HTML_TEXT")
// 템플릿 언더바의 구성 요소
val OUTER_ELEMENT_TYPE: IElementType = TplOuterElementType("TPL_OUTER_ELEMENT_TYPE")
// 템플릿 데이터에 대한 형식
val TEMPLATE_DATA: TemplateDataElementType = TplTemplateDataElementType()
// 템플릿 언어에 대한 파일 노드
val FILE: IFileElementType = IFileElementType("FILE", TplLanguage)
}
override fun getTemplateFileLanguage(viewProvider: TemplateLanguageFileViewProvider?): Language {
return HTMLLanguage.INSTANCE
}
}
Kotlin
복사
•
또한 TemplateDataElementType을 구성할 때, 생성자의 3, 4 번째 인자가 매우 중요합니다.
•
3번 째 인자에는, 실제로 OUTER_TEXT와 연계되는 object를 할당시켜야 합니다.
◦
여기서는 TEMPLATE_HTML_TEXT 가 될 것입니다.
◦
그리고 TEMPALTE_HTML_TEXT는 반드시 .bnf가 생성한 Types 인터페이스에서 해당 값으로 반드시 치환해야 합니다.
◦
그니까, .bnf가 수정되면, OUTER_TEXT의 값은 해당 값으로 항상 수정이 필요합니다.
•
4번 째 인자는 다음과 따로 특정 타입을 만들어야 합니다.
class TplOuterElementType(@NonNls debugName: String) : IElementType(debugName, TplLanguage), ILeafElementType {
override fun createLeafNode(text: CharSequence): ASTNode {
return OuterLanguageElementImpl(this, text)
}
}
Kotlin
복사
◦
이는 HTML_TEXT가 담길 Element 타입을 지정합니다.
•
또한, 더 나아가서 또 다른 함정카드가 존재하는데요
/**
* 해당 언어에 대한 토큰 구성요소 정의하기.
*/
class TplParserDefinition : ParserDefinition {
override fun createLexer(project: Project?): Lexer = TplLexerAdapter()
override fun createParser(project: Project?): PsiParser = TplParser()
override fun getFileNodeType(): IFileElementType = TplTemplateDataElementType.FILE
override fun getCommentTokens(): TokenSet = TokenSet.create(TplTypes.COMMENT)
override fun getStringLiteralElements(): TokenSet = TokenSet.EMPTY
override fun getWhitespaceTokens(): TokenSet = TokenSet.create(TplTypes.WHITE_SPACE)
// !===== 여기 부분 ======!
override fun createElement(p0: ASTNode): PsiElement {
return ASTWrapperPsiElement(p0)
}
override fun createFile(viewProvider: FileViewProvider): PsiFile = TplFile(viewProvider)
override fun spaceExistenceTypeBetweenTokens(left: ASTNode?, right: ASTNode?): ParserDefinition.SpaceRequirements {
return ParserDefinition.SpaceRequirements.MAY
}
}
Kotlin
복사
•
기본적으로 ChatGPT를 통해, 알려달라고 하면, ParserDefinition을 작성할 때,
•
createElement 값을, Types.Factory() 라는 형태로, 기본적으로 .bnf로 생성된 Type 팩토리 메서드를 알려주게 됩니다. 실제로 당연히 자동으로 생성되는 메서드입니다.
•
하지만, 여기서 놀라운 점은, 자동으로 만들어주는 Factory는 HTML 데이터를 처리할 구현체가 존재하지 않습니다. 그렇다고 그걸 다 만드는 것도 상당히 힘든 노가다 입니다. (특정 타입이 없으면, 구조가 올바르게 생성 되지 않음!)
•
그래서, 템플릿 언어를 작성할 때는 여기 createElement 부분을 자동으로 만들어진 Factory를 포기하고, 그냥 범용적으로 사용하는 ASTWrapperPsiElement로 생성하도록 합니다.
•
그럼 PsiElement를 범용적으로 모두 다 공평하게 만들 수 있게 됩니다. 이건 좀 매우 불편해서.. 좀 개선해주었으면 좋겠습니다..
•
왜냐하면, 범용적으로 만든 PsiElement는 .bnf가 java코드로 만든 PsiElement의 구현체를 사용하지 않는 다는 의미와도 같습니다.
•
다행히도, PsiElement는 범용적으로 IElementType을 가지고 있으므로, 자동완성이나, 노드를 탐색하고 비교해야 할 일이 있으면, 반드시 PsiElement에서 IElementType을 추출해서, 비교해야 합니다. 생각 없이 PsiElement 자체로 비교하면, 아무런 결과도 얻지 못할 것입니다.
◦
제가 이를 눈치챌 수 있었던 것도, PsiViewer 플러그인에 구현된 패키지 경로까지 명시해주기 때문에 쉽게 찾을 수 있었습니다!
개발해야 되는 기능
•
문법 하이라이팅
•
IF, LOOP문 폴딩
•
자동완성
크게는 위의 3가지가 메인 기능입니다.
HighLighting도 마찬가지로, Provider를 통해 구현해야 됩니다. 여기서도 구분자로 OUTER_TEXT 를 통해서, 하이라이팅에 대한 구현체를 결정하게 됩니다.
OUTER_TEXT를 만나는 경우에는 일반 HTML 하이라이팅, 그 외는 우리의 문법 하이라이팅을 적용하도록 하는 것이죠!
그리고 하이라이팅은 문법을 보기 좋게 예쁜 색상으로 꾸며주는데, 이는 AST와 전혀 관계가 없습니다.
하이라이팅은 AST와 별개로 동작합니다. 즉, AST가 옳지 않게 구성되었다고 하더라도, 하이라이팅은 .flex 파일과 토큰 별 색상 매칭만 잘 되어있다면, 별개로 잘동작합니다.
이게 어떻게 보면, 왜 이런 설계가 되었는지는 모르겠는데, 문법이 잘 하이라이팅이 되었다고, AST도 잘 만들어졌을 거라는 착각을 일으키는 원흉이기도 합니다.
완전히 별개라는 점을 아셔야 합니다!
폴딩 기능은, FoldingBuilder를 통해서, 구현합니다. Folding은 당연히 문법의 영향을 받으므로, AST가 반드시 옳은 구조로 형성되어있어야 합니다.
블록을 찾으면, 첫번째 자식과 마지막 자식을 하나의 간결한 형태로 압축시켜 표기하는 방식을 사용합니다.
이를 구현하면, 특정 IF문, LOOP은 접어서 짧게 코드를 파악할 수 있게 도와줍니다.
자동완성은, completionContributor와 TypedHandler를 통해 구현됩니다.
completionContributor에서는 Tpl 문법이 현재 어느 위치에 있는지 인식시켜주고,
주변의 문자를 조사하여, 본인이 필요한 자동완성 내역을 보여주도록 하는 방식입니다.
TypedHandler는 기본적으로 . 과 같은 입력, 수동으로 Ctrl+Space를 발동하여, 자동완성 팝업을 띄울 수 있는데, 그 외에도 어떤 문자를 사용하여, 자동완성 팝업을 띄울지 결정하는 역할을 합니다.
TypedHandler는 내가 현재 작성한 코드가, 문법 내부에 동작해야지만 동작하므로, Lexer를 잘 구성하여, 현재 작성하는 코드가 문법 내부에 들어올 수 있게 처리해야 합니다.
아니면, 만약에 { 라는 글자를 입력하였다면, 자동으로 } 라는 내용이 추가될 수 있도록 미리 구성하여,
자동적으로 문법 내부에 들어올 수 밖에 없는 상황을 연출하여, 자동완성을 유도할 수도 있습니다.
적용 및 테스트
•
인텔리제이 에서는 플러그인을 쉽게 테스트 할 수 있습니다.
1.
상단에 Run With IDE를 통해 플러그인을 바로 IDE위에 올려서 테스트할 수 있습니다.
•
시작을 하게 되면, IDE가 새롭게 뜨게 됩니다. 바로 프로젝트를 구성하고 시작하면, 플러그인이 적용된 상태로 테스트 할 수 있습니다.
•
또한, print문의 내용을 확인 할 수도 있고, 디버그 기능을 통해서, break 포인트를 걸어 변수 값을 실시간으로 확인하면서 테스트도 가능합니다.
•
하지만, 별도의 추가 플러그인을 설치해서 동작시키지 못해, PsiViewer를 사용할 수는 없습니다.
2.
직접 빌드하고, 적용하기
•
미리 한 번 Gradle 메뉴에서, Tasks → intellij platform → buildPlugin을 실행합니다.
•
해당 실행 이후, 상단에서 바로 실행할 수 있도록, 목록이 추가됩니다.
•
build되면 빌드된 파일은
◦
build/distributions/플러그인명-버전.zip 형태로 결과가 나오게 됩니다.
•
PhpStorm에서 플러그인에 들어가서, 상단의 톱니바퀴를 클릭하고,
◦
“디스크에서 플러그인 설치…” 를 클릭하여, 빌드된 zip 파일을 불러오면 됩니다.
◦
이미 설치된 경우, “IDE 다시시작” 버튼이 초록색으로 활성화되며, 반드시 다시 시작을 눌러야 새로운 버전의 플러그인이 적용됩니다.
마무리
연말 동안에 재밌는 작업을 진행해서 좋았습니다.
꽤 길게 쉬었지만, 대부분을 플러그인 개발하는데 시간을 투자해서, 시간이 삭제 되었습니다.
그 만큼, IDE 플러그인을 만드는 작업은 생각보다 많이 재밌었습니다!
여러분들도, 심심하면 간단한 IDE 플러그인 만들어보는 것을 추천드립니다.
•
PHP 함수 자동완성
•
LOOP 블록 닫히지 않음
•
의미없는 닫기 블록 감지
•
반복문에서 사용되는, 예약어 자동완성
•
반복문 depth가 깊어질 수록, 추천되는 예약어의 종류
•
반복문에서 사용할 수 없는 예약어 감지
•
폴딩















