Stela 모듈은 byeol 언어의 경량화된 버전으로, manifest나 옵션과 같은 특수 목적용 DSL(Domain Specific Language)입니다. Composite 패턴을 사용하여 트리 구조로 설정 데이터를 표현하며, Core 모듈의 node 와 유사한 구조를 가집니다.
Stela 언어는 byeol 언어보다 덜 복잡하므로, Core 모듈의 byeol 파서를 보기 전에 Stela 파서 코드를 먼저 살펴볼 것을 권장합니다. 파서의 기본 구조와 동작 방식을 이해하는데 더 적합해요.
stela 모듈의 주요 클래스:
stela 클래스는 Stela 모듈의 가장 기본 단위가 되는 클래스로, stela 코드를 파싱후 최종 결과를 나타냅니다. 다음의 기능을 제공합니다:
verStela(22)의 경우 asStr()을 하면 std::string("22")가 반환됩니다.사용 예제
nulStela 는 stela 로 자식 객체를 가져올 때 보통 이름으로 가져오는데, 해당 이름을 가진 자식 객체가 없을 경우 대신 반환됩니다.
nulStela 는 null object 패턴을 구현한 것으로, 해당 객체에 대해 값 변환을 요청할 경우 항상 해당 타입의 기본값이 반환됩니다. stela 객체가 nulStela 인지 확인하려면 isExist() 혹은 operator bool()이 false인지 확인하면 됩니다.
사용 예제
valStela 는 nulStela 와 달리 int, float, string 등 scalar 타입을 가지고 있는 stela 입니다. 내부적으로는 기본 문자열로 값을 가지고 있는 상태이며, asInt()와 같은 타입변환 요청에 따라서 적절한 타입으로 변경할 수 있습니다.
만약 타입변환에 실패한다면, 예외가 발생하니 주의하세요.
verStela 는 valStela 와 비슷하게 version이라는 타입의 값을 가지고 있는 stela 입니다.
version 타입
stela 언어는 byeol 언어의 경량화된 언어로, manifest나 옵션과 같은 특수 목적용 언어입니다. version 타입은 major.minor.fix의 3가지 변수를 가지고 있으며 범위 표현 또한 가능한 타입입니다.
사용 예제
stelaParser 클래스는 stela 파싱 컴포넌트의 진입점 역할을 합니다. parse() 나 parseFromFile()을 통해서 스크립트를 지정하면 파싱된 결과가 stela 구조로 반환됩니다.
byeol 파서와 유사한 구조
Stela 언어 자체가 byeol의 특화된 언어이므로 파서 또한 byeol 언어의 파서를 기반으로 하고 있습니다. byeol 파서 대비 덜 복잡하여 파서의 기본 동작을 이해하기 좋습니다.
scanner - bison - stelaParser 구조
Flex/Bison 기반의 파서를 구현하고 있으며, flex는 lowscanner로, bison은 lowparser로 각각 명명합니다. 이 lowlevel scanner, parser는 parser 컴포넌트 안에만 존재하는 것으로 외부에서는 일절 노출 되지 않습니다.
parse() 가 실행되면 lowscanner를 실행시키고, lowscanner는 토큰을 뜯어서 lowparser에게 넘기고, lowparser는 받은 토큰에 대해 rule이 match 되면 그 이벤트를 다시 stelaParser 에게 넘깁니다.
stelaParser 의 on으로 시작하는 함수들은 그러한 이벤트를 handling 하는 Callback 함수로, 실제로 어떻게 stela를 생성해서 AST를 구축하는지를 정의합니다.
stela 언어는 byeol 언어와 마찬가지로 offside rule을 적용하므로, indentation에 매우 민감합니다. 일반적인 언어와 달리, 개행직후로 몇개의 공백이 있는가를 count 할 수 있어야 하며, indentation이 확정되어 해당 코드라인이 어느 scope에 속한 것인지가 확정되면 이후로는 공백을 무시해야 합니다.
다음 byeol 언어의 예제를 보고, 이 문제에 대해 더 생각해봅시다:
파서가 print("0 < val < 5")를 파싱하고 나서 다음 줄인 print("end of func")를 파싱할때 앞에 공백이 몇개 있는지 세야 합니다. 그리고 지금까지 각 scope별 공백 갯수와 비교해서 해당 코드라인이 어느 scope에 속한 것인지를 판단해야 합니다.
위 예제를 보면, 우리는 직관적으로 print("end of func")이 if val > 0과 같은 공백을 갖고 있다는 점에서, foo() 본문 바로 안에 속한 코드라는 걸 알 수 있습니다.
그렇다는 건, 이 시점에서 if val > 0 안쪽의 scope와 if val < 5 안쪽의 scope 2개 모두 종료되었으므로 lowscanner는 lowparser가 print("end of func")를 인식하기 전에 scope의 종료를 의미하는 DEDENT 토큰을 2개 먼저 인식 할 수 있도록 만들어야 합니다.
이를 해결하기 위해서 stelaSmartDedent 는 현재 scope 당 몇 개의 indentation space를 가지고 있는지 배열로 관리합니다. 예를들어 다음의 stela 코드를 파싱한다고 해봐요:
만약 마지막 위치에서 파서가 파싱중일때, stelaSmartDedent 객체는
[0, 4, 6]
으로 배열 값이 할당되어 있게됩니다. 각각은 가장 외곽 scope부터 몇 번의 공백으로 구분되고 있는지를 나타냅니다. 즉 가장 바깥의 scope의 공백의 수는 0 이 되며, 가장 안쪽의 scope인 device의 객체 scope의 필요한 공백의 수는
smartDedent[smartDedent.len() - 1] = 6
으로 개행 직후 6개의 공백이 나와야 한다는 걸 의미합니다.
flex는 yyin 이라는 별도로 지정된 stream을 통해서 글자를 가져와 token으로 정의합니다. 파싱 도중에 문자를 추가 꽤 빈번하게 발생하며, 이때 보통은 unput()을 사용합니다.
위의 예제에서 } 토큰을 scan시, )가 parser로 전달되며, lowscanner의 input stream에는 as str + 가 맨 앞에 추가됩니다. unput()은 stream의 가장 앞에 문자를 넣기 때문에 역순으로 unput을 하고 있다는 점을 주의깊게 보세요.
이처럼 기본적으로 unput을 사용하나, 여러개를 unput 하거나 뒤가 아니라 앞에 push 하는 경우 등에 유연하게 대응하기 위해, stelaLowscanner는 내부적으로 stelaTokenDispatcher 를 사용합니다. 이는 내부적으로 Queue를 사용하여 토큰들을 버퍼링하고 순차적으로 전달합니다.
unput()과 tokenDispatcher의 차이점
unput()과 tokenDispatcher 는 근본적으로 다른 동작 방식을 가지고 있습니다. unput()은 스트림 자체에 문자를 추가하는 반면, tokenDispatcher 는 렉서를 건너뛰고 이미 결정된 토큰을 강제로 반환합니다.
이 차이는 매우 중요한데요, unput()은 문자 단위로 작동하여 렉서가 다시 토큰을 분석하게 하지만, tokenDispatcher 는 토큰 단위로 작동하여 렉서의 분석 과정을 완전히 우회하거든요. 예시를 한번 보죠.
위 코드가 ‘unput('e’); unput('m'); unput('a'); unput('n');과 <b>전혀 다르게 동작</b>합니다. unput()을 사용하면 렉서가 스트림에서 "name"이라는 문자열을 읽고 이를STRVAL토큰 하나로 인식합니다. 하지만 @ref by::tokenDispatcher "tokenDispatcher" 를 사용하면'n','a','m','e'` 토큰을 개별적으로 파서에 반환하게 되며, 이는 파싱 오류를 일으킵니다.
주의사항: dispatcher 사용 후 반드시 토큰을 리턴해야 합니다
tokenDispatcher 를 사용할 때는 flex rule 안에서 반드시 토큰을 리턴해야 합니다. 토큰을 리턴하지 않으면 dispatcher가 동작하지 않습니다. 이는 flex의 구현 방식 때문입니다:
stelaParser 는 indentation을 정밀하게 측정하기 위해서 scan mode를 동적으로 변경해야 합니다. tokenScan은 그러한 스캔 모드 전략 1개를 담당하며, Strategy 패턴을 사용하여 런타임에 스캔 알고리즘을 교체할 수 있습니다.
tokenScan의 동적 전환
앞서서 indentation을 탐지하는 게 얼마나 중요한지 설명했습니다. 정확한 공백의 갯수를 셈해야 하기 때문에 normalScan과 indentScan 2개의 scan 객체를 전환해가며 사용합니다. 이는 Strategy 패턴의 전형적인 사용 예로, 상황에 따라 다른 스캔 전략을 선택합니다.
개행이 탐지되면 indentScan으로 교체해서 정확하게 공백을 count해서 scope를 결정하고, 이후에는 normalScan으로 교체해서 평상시처럼 공백을 다 무시합니다.
indentation 검사
명령 token
token 중에는 SCAN_AGAIN, SCAN_EXIT 등 scanner나 parser에 명령을 주는 토큰들이 존재합니다. 이러한 토큰들은 일반적인 데이터 토큰과 달리, 토큰 자체가 특정 동작을 수행하도록 지시하는 명령 역할을 합니다. 자세한 내용은 stelaParser 의 zztokenType enum을 참조하세요.
isBypass
IndentScan의 경우 대부분의 token을 무시하며 오직 공백이 몇개인지 갯수를 세는 데 집중합니다. 그리고 indentation이 가장 최근 scope의 indentation과 차이가 발생한 경우 DEDENT 혹은 INDENT token을 dispatcher에 추가합니다.
하지만 이전 라인에서 여러 token을 push 해둔 상황이라면 내부적으로 bypass 모드로 동작합니다. 이때는 indentation 갯수를 세는 동작을 skip 하고 넣어둔 token을 그대로 읽어서 반환합니다.
tokenScan 사용 예제
offside-rule에 의해 byeol은 개행이 있는지 여부가 매우 중요하다고 말씀드렸죠. byeol은 표현식 기반 문법을 가지고 있기 때문에 for나 if 문이 함수 인자로 오는 것도 가능해야 합니다. 이때 사용자는 간단하게 사용하기 위해 보통은 :을 사용하는 inline block 문법을 사용하게 되는데, 위의 3가지가 한번에 적용되기 시작하면 예외사항이 나오기 마련이죠.
함수 뒤에는 블록문이 옵니다. 블록문은 <표현식> <개행> 이 매치되면 구문으로 인식되고 이러한 구문들만 블록문에 들어올 수 있습니다. 1번의 경우처럼 inline block을 쓸 때도 뒤에 <개행>이 온다는 점은 유지되어야 합니다. 하지만 3번을 보세요. inline block 뒤에 콤마가 나오는데, 이 경우 inline block의 <개행>은 없습니다. 그렇다고 단순히 inline block은 개행이 있을 수도 있고 없을 수도 있다 라고 규칙을 정해버리면 모호한 경우가 너무 많이 생깁니다.
그래서 stelaSmartDedent 가 나옵니다. 위와 같이 inline block을 블록을 사용하되, 콤마로 끝나는 경우는 개행을 추가해주는 아주 단순하지만 parser의 rule의 난이도를 낮추는 역할을 합니다.
그럼, 이번에도 마찬가지로 각 클래스/컴포넌트 간의 흐름을 정리하겠습니다.
다음 문서: core 모듈 - 언어 구현의 핵심