- llamafile용 새 구문 하이라이터를 만들기 위해 42개 언어를 다루자, 단순한 렉싱에도 언어별 예외와 오래된 문법이 촘촘히 숨어 있음이 드러남
- 구현은 C++와 GNU gperf로 키워드 조회를 빠르게 만들고, 문자열·주석·키워드 중심 처리는
for루프와switch기반 유한 상태 머신으로 해결함 - C의 trigraph, JavaScript의
u2028·u2029줄 종결자, Shell·Perl·Ruby의 heredoc, Kotlin·Scala·TypeScript·Swift의 문자열 보간은 렉싱만으로 처리하기 까다로운 사례로 반복 등장함 - 코드 줄 수 기준으로 FORTH는 125줄, Ruby는 1042줄까지 벌어지며, Ruby는
<<연산자·heredoc·backquote가 충돌해 파싱 없는 하이라이팅이 특히 어려움 - llamafile의 새 하이라이터는 Windows 10에서 Meta LLaMA 3.2 3B Instruct로 시연됐고 macOS·Linux·FreeBSD·NetBSD에서도 실행 가능해, 구문 하이라이팅이 없는 ollama와 차별화됨
llamafile 하이라이터를 만든 계기
- llamafile용 새 구문 하이라이터를 만들기 위해 한 달 동안 42개 프로그래밍 언어를 학습함
- 지원 대상은 Ada, Assembly, BASIC, C, C#, C++, COBOL, CSS, D, FORTH, FORTRAN, Go, Haskell, HTML, Java, JavaScript, Julia, JSON, Kotlin, ld, LISP, Lua, m4, Make, Markdown, MATLAB, Pascal, Perl, PHP, Python, R, Ruby, Rust, Scala, Shell, SQL, Swift, Tcl, TeX, TXT, TypeScript, Zig임
- 이 목록은 TIOBE Index의 대부분을 포함하지만, Scratch는 텍스트가 아니라 블록을 쓰므로 하이라이팅 대상에서 제외됨
구현 방식: gperf와 유한 상태 머신
- 기본 구문 하이라이터에서 가장 큰 병목은 토큰이 키워드인지 판별할 때 반복적인 문자열 비교가 늘어나는 지점임
- C++와 GNU gperf를 사용해 완전 해시 테이블을 생성함
- 예시
gperf입력은true,false,null같은 Java 상수를 키워드로 정의함 gperf가 생성한 C 파일은 충돌 없는 조회를 위해 단일 문자만 고려하는 해시 함수를 만들 수 있음
- 예시
- C 하이라이팅은 약 4,000개 키워드를 정의해도 gperf 덕분에 35 MB/s 속도로 처리 가능함
- 나머지 처리는 대부분 유한 상태 머신으로 충분함
flex,bison,ragel없이for루프와switch만으로 기본 하이라이터를 만들 수 있음- 문자열, 주석, 키워드에 집중하면 대체로 렉싱 수준에서 처리됨
- C 함수 이름 같은 요소까지 하이라이트하려면 실제 파싱이 필요할 수 있음
- highlight_ada.cpp가 예시 구현으로 사용됨
llamafile 데모와 사용 환경
- 새 하이라이터와 챗봇 인터페이스는 llamafile 사용성을 높였고, ollama에는 없는 구문 하이라이팅이 차별점이 됨
- 데모는 Windows 10에서 Meta LLaMA 3.2 3B Instruct 모델로 실행됨
- 해당 llamafile은 macOS, Linux, FreeBSD, NetBSD에서도 실행 가능함
- gemma 27b it 같은 오픈 가중치 모델의 품질이 좋아지면서 Claude를 사용할 유인이 줄어듦
C: 단순한 언어라는 인상과 다른 렉싱 예외
- C는 단순하다는 인상과 달리 렉시컬 요소가 매우 특이한 편임
- trigraph는
#,[,\,^,{,|,},~같은 문자를??=,??(,??/,??),??',??<,??!,??>,??-로 대체할 수 있음- C23 표준에서 제거됐지만, 레거시 소프트웨어 때문에 컴파일러는 계속 지원할 것으로 보임
- 좋은 구문 하이라이터라면 이 문법도 처리해야 함
- C의 universal character는
int \uFEB2 = 1;처럼 ASCII 소스 코드 안에서 유니코드 식별자를 표현할 수 있음- GCC는 표준 위원회가 허용한 특정 유니코드 plane이 아니면 오류를 냄
- C의 한 줄 주석은 줄 끝 백슬래시를 사용하면 여러 줄에 걸칠 수 있음
- Perl, Ruby, Shell처럼 소스 코드에서 백슬래시 이스케이프를 허용하는 언어도 이 C식 동작은 지원하지 않음
- Tcl과 GNU Make는 이 동작을 지원하는 것으로 파악됨
- Emacs와 Pygments는 이를 틀리게 처리하는 경우가 있고, Vim은 백슬래시 처리를 항상 맞게 하는 것으로 보임
- C에는 null preprocessor directive도 있음
- 초기 v6 소스 코드의
.c파일은#한 줄로 시작하는 경우가 많음 - 현재도 유효한 코드이며,
cc -C -E로 주석 보존을 요청해도 전처리기가 특정 주석을 제거하게 만드는 용도로 쓸 수 있음
- 초기 v6 소스 코드의
주석 문법: Haskell과 D
- C에서는 여러 줄 주석 안에 다시 여러 줄 주석을 중첩할 수 없음
- Haskell은
{- ... {- ... -} ... -}형태로 중첩 주석을 지원함 - D는 C의
//,/* ... */주석을 그대로 채택하고, 별도로/+ ... +/형태의 재귀 주석 문법을 도입함 - D의 렉시컬 문법 문서는 형식적이고 상세해 구현에 필요한 정보를 잘 제공함
- D lexical syntax 문서는 hex 문자열과 heredoc 문자열 같은 세부 사항을 다룸
- D에는
"...", backtick 문자열,r"...",q"...",x"..."등 다양한 문자열 형태가 있음
Tcl과 JavaScript: 따옴표와 보이지 않는 줄 종결자
- Tcl에서는 식별자 안에 따옴표가 들어갈 수 있음
puts a"b는a"b를 출력함- 변수 이름에도 따옴표를 넣을 수 있지만, 참조할 때는
$a"b가 아니라${a"b}표기를 사용해야 함
- JavaScript는 정규식을 위한 내장 렉시컬 문법을 갖고 있음
/[/]/g처럼 문자 집합 대괄호 안의/는 이스케이프하지 않아도 됨- 닫는 슬래시만 단순히 스캔하는 방식은 minified 코드에서 틀릴 수 있음
- ECMAScript는
u2028LINE SEPARATOR와u2029PARAGRAPH SEPARATOR를 줄 종결자로 정의함- 이 문자는 사실상
\n처럼 동작함 - Trojan Source 문자이므로 Emacs에서는 각각
↵,¶로 보이도록 설정함 - 많은 소프트웨어는 이 문자를 인식하지 못해 물음표로 렌더링하는 경우가 있음
- D를 제외하면 이런 처리를 하는 다른 언어는 알지 못한다고 함
- 이 문자는 사실상
- 이 특성을 이용해 SectorLISP에서 C와 JavaScript polyglot을 만들 수 있었음
- lisp.js는 브라우저에서 실행되고 GCC로 컴파일해 로컬에서도 실행될 수 있음
- llamafile은 이런 코드를 올바르게 하이라이트하지만, 다른 하이라이터에서는 같은 처리를 찾지 못했다고 함
Shell과 heredoc의 모서리 사례
- Shell의 heredoc은
cat <<EOF ... EOF형태로 여러 줄 문자열을 작성함 - quoted heredoc 문법인
cat <<'END'는 변수 치환을 비활성화함 - heredoc 마커를 빈 문자열로 두면 다음 빈 줄에서 heredoc이 끝남
- 예시 프로그램은
hello와world를 두 줄로 출력함
- 예시 프로그램은
- Shell, Ruby, Perl처럼 heredoc을 지원하는 언어에서는 같은 줄에 여러 heredoc을 둘 수 있음
- Shell은 Tcl처럼
#가 항상 주석을 시작하지 않음${x#hi-}에서#는 접두사를 제거하는 데 쓰이며, 예시는there를 출력함
문자열 보간: 상태 스택이 필요한 언어들
- Kotlin 문자열은
"로 시작했더라도 문자열 보간 때문에{에서 다른 상태로 들어갈 수 있음 - TypeScript, Swift, Kotlin, Scala는 문자열 안에 실제 코드를 넣는 방향으로 문자열 보간을 강하게 지원함
- Kotlin, Scala, TypeScript를 하이라이트하려면 중괄호를 세고 파서 상태 스택을 유지해야 함
- TypeScript는 비교적 단순해 유한 상태 머신에 몇 가지 상태만 추가하면 됨
- Kotlin과 Scala는 double quote와 triple quote 문법 모두에서 보간을 지원하므로 문자열 렉싱에만 약 13개 독립 상태가 필요했음
- Swift는
"\(var)"보간 문법과 triple quote를 지원하지만, 구현에는 10개 상태가 필요했음
Swift, C#, FORTH: 문자열을 감싸는 방식의 차이
- Swift는
"...","""...""",/regex/문자열을 임의 개수의#로 감싸는 문법을 지원함- 양쪽의
#개수는 서로 맞아야 함 - 이 방식은 문자열 안에 따옴표나 정규식 경계를 넣는 문제를 해결함
- 양쪽의
- C#은 Python의 triple quote 다중 줄 문자열과 비슷하지만, 더 많은 개수의 따옴표를 시작과 끝에 맞춰 사용할 수 있음
- 왼쪽에 둔 따옴표 개수가 오른쪽 종료 조건이 됨
- 임의 개수의 따옴표를 허용하면 고전적인 Python triple quote보다 유효성 규칙이 줄어 유한 상태 머신으로 더 단순하게 디코딩할 수 있다고 봄
- FORTH는 공백 경계로 모든 것을 토큰화함
- 문자열 시작 문법인
c"도 하나의 토큰임 c" hello world"는 다른 언어의"hello world"와 같은 의미임
- 문자열 시작 문법인
FORTRAN과 COBOL: 고정 컬럼 규칙
- llamafile은 FORTRAN과 COBOL 프로그래머가 은퇴한 뒤에도 은행 시스템 유지보수에 도움을 줄 수 있는 사용 사례로 제시됨
- 에어갭 환경에서 제어 가능한 AI인 Gemma 27b에 COBOL과 FORTRAN 코드를 요청할 수 있다고 함
- FORTRAN의 고정 컬럼 규칙은 다음과 같음
- 1열에
*,c,C를 두면 그 줄은 주석이 됨 - 6열에 공백이 아닌 문자를 두면 80자를 넘는 줄을 이어 쓸 수 있음
- 1~5열에 숫자를 두면 라벨이 됨
- 1열에
- COBOL의 규칙은 다음과 같음
- 7열에
*를 두면 주석이 됨 - 7열에
-를 두면 80자를 넘는 줄을 이어 쓸 수 있음 - 1~6열에는 줄 번호가 들어감
- 7열에
Zig와 Lua: 다중 줄 문자열의 다른 해법
- Zig는 두 개의 백슬래시로 시작하는 다중 줄 문자열 문법을 가짐
- 이 문법은 Python triple quote 문자열에서
textwrap.dedent()를 호출해야 하는 필요를 없앰 - 단점으로 세미콜론이 보기 좋지 않다고 봄
- Go, Scala, Python처럼 세미콜론이 필요 없는 언어가 고려할 만한 문자열 문법으로 제안됨
- 이 문법은 Python triple quote 문자열에서
- Lua의 다중 줄 문자열은
[[...]]를 기반으로 하며, 대괄호 사이에 임의 개수의=를 넣을 수 있음[==[ ... ]==]처럼 시작과 끝의=개수를 맞춤- 같은 방식을 주석에도 사용할 수 있음
--[[ ... ]]와--[==[ ... ]==]형태가 모두 가능함
Assembly: 방언과 전처리기의 조합
- Assembly는 여러 방언이 갈라져 있어 하이라이트하기 어려운 언어 중 하나임
- llamafile은 AT&T, nasm 등 여러 문법을 합리적으로 처리하는 것을 목표로 함
- 키워드는 줄에서 첫 번째 식별자이며 콜론 뒤에 오지 않는 항목으로 처리하면 대부분의 assembly 코드를 괜찮게 보이게 할 수 있음
- 주석 문법도 단순하지 않음
- 원래 UNIX 주석은 단일
/만 필요했음 - GNU as는 줄 시작 위치에서만 이 주석을 아직 지원함
- Clang은 fixed comment를 지원하지 않아 오픈소스 코드에서 실용적으로 쓰기 어렵다고 함
- 원래 UNIX 주석은 단일
- 원래 UNIX assembler는 문자 리터럴에 닫는 따옴표를 쓰지 않았음
'x가 문자x의 값0x78을 나타냄- GNU as는 이를 계속 지원하지만 LLVM은 지원하지 않음
- 기존 코드에 이 문법이 있으므로 좋은 하이라이터는 지원해야 함
- GNU assembler는 인용된 식별자를 허용해 심볼에 거의 모든 문자를 넣을 수 있음
- Assembly는 C 전처리기나 m4와 함께 쓰이는 경우가 많음
dnl,m4_dnl,C로 시작하는 줄도 주석으로 처리해야 함
Ada와 BASIC: 작은 문법이 렉싱을 흔드는 사례
- Ada는 렉싱하기 상당히 단순하지만, 작은따옴표 사용이 특이함
- C처럼
'x'문자 리터럴을 가질 수 있음 Foo'Size처럼 속성을 참조할 때도 작은따옴표를 사용함Character'(')')'Image는 문자를 선언하고Image함수를 통해 문자열 표현으로 변환함
- C처럼
- Commodore BASIC 예시는 구문 하이라이팅의 여러 가정을 깨뜨림
- 문자열은 줄 끝에서 닫는 따옴표를 생략할 수 있음
- 변수 이름에는
$같은 sigil이 붙음 goto같은 키워드는 식별자 안에서도 적극적으로 렉싱됨
- Visual BASIC에는
#1/1/2024#같은 날짜 리터럴 문법이 있음 - Visual BASIC은
#If DEBUG Then,#Else,#End If같은 전처리기 지시문도 갖고 있어 렉싱이 까다로움
Perl: Shell과 프로그래밍 언어 사이의 복잡성
- Perl은 Shell과 프로그래밍 언어 사이에 있어 양쪽의 복잡성을 모두 물려받음
- 정규식을 언어의 일급 요소로 만들었고, 그 영향은 Python 등 다른 언어에도 이어짐
- Perl의 치환 문법은
s/hello/Perl/i처럼 sed와 유사함/대신 임의의 구두점 문자를 구분자로 쓸 수 있음s!hello!Perl!i처럼 슬래시가 정규식 안에 들어갈 때 편리함s{hello}{Perl}i처럼 짝이 있는 문자를 쓰면 추가 문자가 필요함
- Perl에는 문자열처럼 하이라이트해야 하는 마법 접두사가 많음
m,s,y,qr,qw,qq,qx등이 다양한 구분자와 함께 쓰임
y/x/y/를 나눗셈으로 잘못 판단하지 않으려면 문맥을 봐야 함- Perl 변수는 스칼라
$, 배열@, 해시%같은 sigil을 갖기 때문에 문법 전체를 파싱하지 않고도 구분에 도움이 됨
- Perl 변수는 스칼라
- Perl은 소스 코드 안에 man page용 POD 문서를 넣는 관례가 있음
- 줄 시작의
=word가 POD 문서를 시작하고=cut이 끝냄
- 줄 시작의
Ruby: 가장 어려웠던 렉싱 대상
- Ruby는 이전 언어들의 합집합처럼 보이며, 문법이 형식적으로 충분히 문서화돼 있지 않다고 평가됨
- Ruby 매뉴얼의 syntax 문서는 세부 사항이 가벼운 편으로 봄
- Ruby는 backquote 문법을 지원하면서 동시에 backquote를 메서드 이름처럼 사용할 수 있어 하이라이터가 문자열인지 아닌지 판단하기 어려움
- Ruby에는
<<연산자와 heredoc이 모두 있음- 실제 코드에서
options[:includes] <<arg; true같은 형태가 존재함 - 이 코드는 heredoc처럼 보일 수 있으며 Emacs도 이를 잘못 처리함
- 실제 코드에서
puts "This is #{<<HERE.strip} evil"처럼 문자열 보간 안에 heredoc이 들어가는 코드도 유효함- 42개 언어 중 Ruby가 가장 큰 충격으로 꼽혔고, Ruby는 파싱 없이 렉싱할 수 없을 수 있으며 파싱을 해도 해석 가능 여부를 확신하기 어렵다고 봄
구현 코드 줄 수로 본 복잡도
- 각 언어 하이라이터 구현의 코드 줄 수를 기준으로 하면 FORTH가 가장 단순하고 Ruby가 가장 복잡함
- 가장 짧은 구현은 highlight_forth.cpp 125줄임
- 비교적 짧은 구현에는 m4 132줄, Ada 149줄, LISP 160줄, MATLAB 166줄, COBOL 186줄, BASIC 199줄, FORTRAN 200줄 등이 있음
- 중간 규모 구현에는 JavaScript 337줄, TypeScript 371줄, Kotlin 387줄, Scala 387줄, Assembly 447줄, C 449줄, Swift 455줄, D 521줄이 포함됨
- 가장 긴 구현은 Shell 570줄, Perl 583줄, highlight_ruby.cpp 1042줄임