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")가 반환됩니다.사용 예제
트리를 다루는 API
자식은 이름을 key로 하는 std::map에 담깁니다. 그래서 이름으로 찾는 sub(name) 외에도 n번째 자식을 꺼내는 sub(index), 자식 수를 세는 len(), 존재를 확인하는 has(name), 그리고 직접 순회할 수 있는 begin()과 end()가 제공됩니다. 트리를 고칠 때는 add()와 del()을 씁니다.
여기서 주의할 점이 두 가지 있습니다. 먼저 add()는 자식이 스스로 가진 이름을 key로 삼기 때문에, 같은 이름의 자식이 이미 있으면 덮어씁니다. 그리고 sub(name)은 찾지 못했을 때 nulStela 를 돌려줄 뿐 트리에 빈 자식을 만들어 두지 않습니다. 조회가 트리를 바꾸지 않으므로 has()로 먼저 확인하지 않고 바로 sub()를 불러도 괜찮아요.
자식이 map에 담긴다는 건 순회 순서가 이름의 사전순이라는 뜻이기도 합니다. 원본 소스에 적힌 순서는 보존되지 않습니다. 배열만은 예외인데, 이는 arrStela 에서 설명합니다.
nulStela 는 stela 로 자식 객체를 가져올 때 보통 이름으로 가져오는데, 해당 이름을 가진 자식 객체가 없을 경우 대신 반환됩니다.
nulStela 는 null object 패턴을 구현한 것으로, 해당 객체에 대해 값 변환을 요청할 경우 항상 해당 타입의 기본값이 반환됩니다. stela 객체가 nulStela 인지 확인하려면 isExist() 혹은 operator bool()이 false인지 확인하면 됩니다.
사용 예제
valStela 는 nulStela 와 달리 int, float, string 등 scalar 타입을 가지고 있는 stela 입니다. 내부적으로는 기본 문자열로 값을 가지고 있는 상태이며, asInt()와 같은 타입변환 요청에 따라서 적절한 타입으로 변경할 수 있습니다.
만약 타입변환에 실패한다면, 예외가 발생하니 주의하세요.
valStela 를 상속만 하고 메서드는 추가하지 않는 타입입니다. 파서가 문자열 리터럴에만 이 타입을 붙여 두면, stelaWriter 가 소스로 되쓸 때 count := 3 과 달리 name := "byeol" 만 큰따옴표로 감쌀 수 있습니다. 값 접근은 부모 인터페이스를 그대로 쓰면 됩니다.
verStela 는 valStela 와 비슷하게 version이라는 타입의 값을 가지고 있는 stela 입니다.
version 타입
stela 언어는 byeol 언어의 경량화된 언어로, manifest나 옵션과 같은 특수 목적용 언어입니다. version 타입은 major.minor.patch에 선택적인 네 번째 자리 revision을 더한 형태이며, 범위 표현 또한 가능한 타입입니다.
revision은 같은 상위 버전을 다시 낸 횟수를 세며, 위쪽 자리가 하나라도 움직이면 0으로 돌아갑니다. 1.2.3.0, 1.2.3.1, 그 다음은 1.2.4.0입니다. .NET의 major.minor.build.revision과 Debian의 upstream_version-debian_revision이 같은 의미로 쓰는 이름입니다. semver에는 네 번째 자리가 아예 없고, 그 +build 메타데이터는 규격상 버전 비교에서 무시되어야 하므로 여기에 쓸 수 없습니다 — revision은 비교에 참여해야 하니까요.
세 자리가 일반적인 경우라 세 자리로 다시 쓰입니다. verStela("1.2")는 "1.2.0"으로 채워지지만, 없는 네 번째 자리는 없는 채로 남습니다. asRevision()이 이를 0으로 읽고, 비교 연산자는 1.2.3과 1.2.3.0을 같은 버전으로 봅니다.
사용 예제
원소를 순서대로 담는 stela 로, 소스에서는 중괄호로 표현합니다.
이 역시 메서드를 추가하지 않습니다. 원소는 평범한 자식이라 sub(index)나 len(), 순회 같은 stela 인터페이스를 그대로 씁니다.
자식 map은 이름순으로 정렬되므로, 파서는 원소에 "0000", "0001" 처럼 0으로 채운 인덱스를 이름으로 붙여 소스 순서를 유지합니다. 자리수는 stelaParser 의 IDX_WIDTH(현재 4)가 정하며, 그만큼 원소 수의 상한이 됩니다.
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의 난이도를 낮추는 역할을 합니다.
파싱이 끝난 stela 트리를 훑으며 소스로 되쓰거나 원하는 노드를 모으는 일은 stelaVisitor 가 맡습니다. byeol Core 모듈의 visitor를 축소한 것이라 구조가 거의 같으니, 이쪽을 먼저 읽고 core를 보면 이해하기 쉽습니다.
한 번의 방문은 위 그림처럼 세 단계로 나뉩니다. onVisit()에서 노드를 살펴보고, 여기서 false를 반환하면 그 아래 서브트리는 통째로 건너뜁니다. true라면 onTraverse()가 자식으로 내려가고, 마지막으로 onLeave()가 불립니다. 순회 방식을 통째로 바꾸고 싶다면 onTraverse()를 재정의합니다.
onVisit()과 onLeave()는 타입별 오버로드를 갖습니다. onVisit(..., strStela&)처럼 관심 있는 타입만 재정의하면 되고, 재정의하지 않은 타입은 super 오버로드로 자동으로 넘어갑니다. 결국 아무것도 재정의하지 않으면 모든 노드가 onVisit(..., stela&)로 모입니다.
트리 순회는 work()로 시작합니다.
visitor가 노드의 실제 타입에 맞는 오버로드를 고르도록, 각 하위 타입은 accept()에서 v.visit(i, *this)를 부릅니다(double dispatch). 이 한 줄은 타입마다 같아서 헤더의 VISIT(), 구현의 DEF_VISIT() 매크로로 처리합니다.
그래서 새 하위 타입을 추가할 때는 세 곳을 손봐야 합니다. visitor/visitee.inl에 X(T)를 등록하고, 헤더에 VISIT(), 구현에 DEF_VISIT()을 넣으면 됩니다.
모든 콜백의 첫 인자로, 지금 노드가 트리의 어디쯤인지 알려줍니다. 이름(name), 부모(parent), 형제 중 위치(index)와 형제 수(len), 루트로부터의 깊이(depth)를 담습니다.
stelaWriter 는 stelaVisitor 를 상속해, 트리를 순회하며 stela 소스 텍스트로 다시 써 주는 클래스입니다. write()는 결과를 문자열로 돌려주고, writeFile()은 곧바로 파일에 씁니다.
주의: writer는 원본을 그대로 복원하지 않습니다. 주석과 원본의 공백·빈 줄은 제거되고, 자식은 이름순으로 재배열되어 write됩니다. 아래 예제도 name, ver, deps 순으로 적었지만 출력은 사전순이 되고, 나중에 추가한 author가 맨 앞에 옵니다.
그럼, 이번에도 마찬가지로 각 클래스/컴포넌트 간의 흐름을 정리하겠습니다.
다음 문서: core 모듈 - 언어 구현의 핵심