파서는 맞는데, 왜 밑줄은 옆에 생길까?
중첩 문법과 한글이 들어간 문서에서도 SevenMark LSP의 오류 표시와 문법 강조 위치를 정확히 맞춘 과정.
Sevenwiki — The Knowledge InfrastructureAugust 31, 2026
처음엔 LSP가 틀린 줄 알았습니다
SevenMark 파서를 LSP에 붙이고 처음 문법 오류를 표시했을 때, 영문 예제는 꽤 멀쩡했습니다.
그런데 한글을 입력하자 밑줄이 몇 글자씩 옆으로 밀렸습니다. 인용문 첫 줄에서는 맞던 표시가 두 번째 줄부터 > 기호 쪽으로 돌아갔고, 목록 안의 링크는 파서가 정확히 읽었는데 문법 강조는 괄호 바깥까지 칠했습니다.
처음에는 LSP 응답을 만드는 코드가 잘못됐다고 생각했습니다. 줄 번호와 시작 위치를 다시 계산해보고, CodeMirror에서 오프셋을 변환하는 부분도 확인했습니다.
문제는 더 앞에 있었습니다.
파서는 가공한 문자열을 읽고 있었고, 에디터는 사용자가 입력한 원문 위에 표시해야 했습니다. 둘이 같은 좌표계를 보고 있지 않았습니다.
숫자 두 개만 저장하면 될 줄 알았습니다
AST 노드에 start와 end를 넣으면 위치 문제는 끝날 것 같았습니다.
실제로 단순한 링크라면 충분합니다.
원문: 앞부분 [[문서]] 뒷부분
^ ^
start end 하지만 인용문 안의 내용을 다시 파싱하려면 보통 각 줄 앞의 >를 제거한 문자열을 만듭니다.
원문
> 첫 번째 줄
> [[두 번째 줄
파서가 읽는 문자열
첫 번째 줄
[[두 번째 줄 첫 줄에서는 인용문이 시작한 위치만 더해도 우연히 맞습니다. 두 번째 줄에 도착하면 중간에서 또 하나의 >가 사라졌습니다. 이제 내부 문자열의 10번째 바이트가 원문의 12번째인지 14번째인지, 시작값 하나만으로는 알 수 없습니다.
목록 들여쓰기나 여러 줄짜리 문법이 겹치면 차이는 더 커집니다.
이때 위치는 단순한 숫자가 아니라 어떻게 원문으로 돌아갈 것인가까지 포함해야 한다는 걸 알게 됐습니다.
한 문서에 좌표계가 세 개나 있었습니다
SevenMark가 에디터에 표시되기까지는 크게 세 종류의 위치를 거칩니다.
1. Rust 문자열과 AST
→ UTF-8 바이트 오프셋
2. 중첩 파서가 읽는 가공 문자열
→ 내부 문자열 기준 오프셋
3. LSP
→ 줄 번호 + UTF-16 코드 단위 Rust 문자열은 UTF-8이므로 한글 한 글자가 보통 3바이트입니다. 반면 LSP의 character는 UTF-16 코드 단위를 사용합니다. 이모지는 한 글자처럼 보여도 UTF-16에서는 두 단위를 차지할 수 있습니다.
파서가 계산한 바이트 오프셋을 LSP의 문자 위치에 그대로 넣으면 영어에서는 맞고 한글과 이모지에서 밀리는 이유가 이것이었습니다.
여기에 인용문 기호를 제거한 내부 문자열까지 들어오니, 위치를 한 번이 아니라 두 번 변환해야 했습니다.
내부 문자열 위치
→ 원문의 UTF-8 바이트 위치
→ LSP의 줄·UTF-16 위치 가공한 문자열마다 지도를 붙였습니다
이 문제를 해결하려고 InputSource가 문자열만 들고 있지 않게 했습니다.
SevenMark 파서의 입력은 실제로 읽을 문자열과, 그 문자열이 원문의 어디에서 왔는지를 나타내는 SourceMap을 함께 가집니다.
원문을 연속된 범위로 잘라 쓴 경우에는 시작점 하나로 충분합니다. 하지만 인용문처럼 중간의 문자를 제거했다면 여러 구간으로 나눠야 합니다.
SourceSegment {
logical_start,
original_start,
len,
} 각 SourceSegment는 내부 문자열의 어느 구간이 원문의 어느 구간과 대응하는지를 기록합니다.
logical: 첫 번째 줄\n[[두 번째 줄
└───────┘ └──────────┘
original: > 첫 번째 줄\n> [[두 번째 줄
segment 1 segment 2 파서가 내부 문자열에서 위치를 얻으면 map_start와 map_end가 해당 구간을 찾아 원문의 바이트 오프셋으로 바꿉니다.
처음에는 AST를 만든 뒤 위치를 보정하려고도 했습니다. 하지만 어떤 기호가 어느 단계에서 제거됐는지를 나중에 역으로 추측하는 것은 점점 불가능해졌습니다.
결국 자식 파서에 문자열을 넘기는 바로 그 순간, 원문으로 돌아갈 지도도 같이 만들어 넘기게 했습니다.
중첩 안의 중첩도 결국 원문을 가리켜야 했습니다
인용문 안에 목록이 있고, 그 목록 안에 링크가 있다면 파서는 여러 번 가공된 문자열을 읽습니다.
원문
↓ `> ` 제거
인용문 내부
↓ 목록 들여쓰기 제거
목록 항목 내부
↓
링크 파싱 여기서 각 단계가 바로 앞 단계의 위치만 기억하면, 최종 링크는 인용문 내부에서의 위치만 알게 됩니다. LSP가 필요한 것은 최상위 원문에서의 위치입니다.
child_source_for_slice는 자식 파서가 읽을 범위와 겹치는 부모의 구간만 골라 새로운 소스 맵을 만듭니다. 자식 문자열의 오프셋은 다시 0부터 시작하지만, 각 구간의 original_start는 계속 최초 원문을 가리킵니다.
그래서 몇 번을 중첩해도 AST에 남는 위치는 항상 사용자가 입력한 원문 기준입니다.
이 구조를 만들면서 UTF-8 문자 경계도 신경 써야 했습니다. 오류 복구 중에 한글의 중간 바이트를 시작점으로 잡으면 나중에 문자열을 자를 때 Rust가 panic합니다. 빈 문자열이나 구간 바깥 위치를 만났을 때 안전한 문자 경계로 보정하는 규칙도 따로 넣었습니다.
밑줄 하나를 맞추려고 시작했는데, 문자열 슬라이스의 안전성까지 따라왔습니다.
UTF-16 변환은 가장 마지막에만 했습니다
AST 내부의 위치를 처음부터 LSP 형식으로 저장하는 방법도 생각할 수 있습니다.
하지만 파서와 렌더러는 Rust 문자열을 다룹니다. 이들에게는 UTF-8 바이트 오프셋이 가장 자연스럽고, 문자열을 자를 때도 바로 사용할 수 있습니다.
그래서 AST의 위치는 끝까지 원문 기준 UTF-8 바이트로 유지합니다. LSP 응답을 만들 때만 LineIndex를 이용해 변환합니다.
원문의 바이트 오프셋
↓ 어느 줄인지 찾기
(줄, 바이트 기준 열)
↓ UTF-16 변환
(줄, UTF-16 character) 시맨틱 토큰은 한 토큰이 여러 줄에 걸칠 수 없으므로, 긴 범위는 줄마다 잘라서 보냅니다. 오류 표시와 접기 범위도 같은 LineIndex를 사용합니다.
기능마다 위치 변환을 따로 구현하지 않은 이유는 간단합니다. 한 군데라도 계산 방식이 다르면 오류 밑줄은 맞는데 자동완성 범위는 틀리는 식의 문제가 다시 생기기 때문입니다.
결국 모든 에디터 기능이 같은 위치를 보고 있었습니다
원문 위치가 정확해지자 여러 기능이 한꺼번에 안정됐습니다.
- 오류가 난 문법에 정확히 밑줄을 그을 수 있었습니다.
- 제목과 접기 문법의 실제 줄을 접을 수 있었습니다.
- 링크 대상과 화면에 표시되는 문자열을 다른 색으로 칠할 수 있었습니다.
- 현재 커서가 어느 AST 노드 안에 있는지 계산할 수 있었습니다.
- 자동완성과 설명, 정의로 이동도 같은 기준을 사용하게 됐습니다.
- 포매터나 리팩터링 기능이 원하는 범위만 안전하게 바꿀 수 있었습니다.
기능은 전혀 달라 보이지만 모두 같은 질문에 의존하고 있었습니다.
이 노드는 사용자가 입력한 원문의 어디에서 왔는가?
이를 각 기능이 알아서 계산하게 하지 않고, 파서가 처음부터 보장하도록 한 것이 가장 큰 변화였습니다.
이런 건 테스트를 안 하면 꼭 영어에서만 맞습니다
위치 계산은 눈으로 몇 번 확인해서 끝낼 수 있는 종류의 코드가 아니었습니다.
영문 한 줄만 테스트하면 대부분 맞아 보입니다. 그래서 한글과 이모지, 여러 줄 인용문, 인용문 안의 목록, 닫히지 않은 괄호, 여러 줄 시맨틱 토큰을 각각 테스트로 남겼습니다.
부모 소스 맵을 자식에게 넘겼을 때 시작과 끝이 같은 방식으로 변환되는지, 빈 입력과 구간 경계에서도 UTF-8 문자의 중간을 가리키지 않는지도 확인합니다.
처음에는 소스 위치를 AST에 붙이는 부가 정보 정도로 생각했습니다. LSP를 실제 편집기에 붙여보니 전혀 아니었습니다.
위치가 틀리면 파서가 문법을 정확히 이해해도 사용자는 틀린 도구를 보게 됩니다. SevenMark에서는 그래서 원문 위치까지 파서 결과의 일부로 다루고 있습니다.
다음 글에서는 이렇게 만든 AST와 위치 정보를 서버뿐 아니라 브라우저의 CodeMirror에서도 같은 구현으로 사용하게 된 과정을 이어서 이야기해보겠습니다.