쿼리 기반 컴파일러 위에 LSP(언어 서버)를 구현해 진단, 정의로 이동, 호버, 참조, 이름 바꾸기, 자동 완성 같은 IDE 기능을 제공하는 방법을 살펴본다.
* [쿼리](https://thunderseethe.dev/posts/lsp-base/#queries)
2026년 1월 21일 · 읽는 데 41분 · 8612단어
언어 만들기
이 글은 언어 만들기 시리즈의 일부다. Rust로 프로그래밍 언어를 구현하는 방법을 가르치는 시리즈다.
이 글은 우리의 모든 패스를 모아 언어 서버를 만든다. 주로 프런트엔드 패스(파싱, 디슈거링, 이름 해결, 타입) 출력에 의존한다.
우리는 그동안 “탄력성”이니 “인터랙티브”니 큰소리쳤다. 이름 해결이 끝났으니, 이제 말만 하지 말고 행동할 차례다. 패스들을 엮어 컴파일러로 만드는 시간이다. 하지만 아무 컴파일러나 만들지는 않는다. 물론 정말 필요하다면 배치 컴파일러를 줄줄이 엮을 수도 있다:
rsfn batch_compiler(source: &str) -> Vec<u8> { let (cst, _) = parser_base::parse(&content); let desugar = desugar_base::desugar(cst); let nameres = name_resolution_base::name_resolution(desugar.ast); let types = types_base::type_infer(nameres.ast); let lower = lowering_base::lower(types.ast, types.scheme); let simple_ir = simplify_base::simplify(lower.ir); let monomorph = monomorph_base::trivial_monomorph(ir); let closures = closure_convert_base::closure_convert(ir); let mut defns = closures.closure_items; let main_defn = ItemId( closures .closure_items .last_key_value() .map(|(key, _)| key.0 + 1) .unwrap_or(0), ); defns.insert(main_defn, closures.item); emit_base::emit_wasm(defns.into_iter().collect()) }
팁
에러 리포팅은 제발 묻지 말아달라. 여기서는 요점을 보여주려는 것이다.
하지만 우리의 목표는 훨씬 더 높다. 우리는 쿼리 기반 컴파일러를 만들 것이다. 그 위에 Language Server Protocol (LSP)을 위한 언어 서버도 구현한다. 모든 것이 한데 모이는 모습은 인터랙티브 플레이그라운드에서 확인할 수 있다.
처음 듣는 사람을 위해 설명하자면, LSP는 에디터와 언어 사이의 M:N 문제를 해결하려고 만들어졌다. 에디터는 많고(Vim, VSCode, Helix 등), 언어도 많다(사실 지금 우리가 그 문제를 더 키우고 있다). 우리는 각 에디터에서 훌륭한 IDE 기능을 원하지만, 언어별·에디터별로 매번 구현하는 것은 조합 폭발(combinatorial explosion)이다.
LSP는 이를 ‘중간자’로 해결한다. 각 언어는 LSP 서버를 한 번 구현하고, 각 에디터는 LSP 클라이언트를 한 번 구현한다. 그러면 서버가 있는 어떤 언어든, 클라이언트가 있는 어떤 에디터에서든 동작한다.
노트
엄밀히 말하면 우리가 구현하는 것은 LSP를 위한 ‘언어 서버’지만, 이 글에서는 편의상 LSP라고 부를 것이다. 흔한 약칭이긴 하지만, 우리는 프로토콜을 만드는 게 아니다. 그건 미친 짓이다.
우리 서버는 호버 시 시맨틱 정보, 자동 완성, 정의로 이동 등 흔히 사랑받는 IDE 기능을 제공한다. 클라이언트는 서버에 연결해 이런 기능을 요청한다. 서버는 컴파일 과정을 씹어 삼키고(JSON 응답을 만들어) 돌려주며, 그 결과를 렌더링하는 책임은 클라이언트가 진다.
최종 결과는 LSP지만, 사실 그것은 수단일 뿐이다. 진짜 목표는 증분(incremental) 쿼리 기반 컴파일러를 만드는 것이다. LSP는 그런 컴파일러의 장점을 완벽하게 드러내는 형태일 뿐이다. 게다가 코드를 다루는 동안 컴파일러가 실시간으로 작동하는 모습을 보는 건 정말 멋지다.
쿼리 기반 컴파일러는 위의 배치 컴파일러 예시와 정확히 반대다. 한 덩어리 파이프라인이 처음부터 끝까지 데이터를 흘려보내는 대신, 컴파일을 ‘질문(쿼리)’들의 집합으로 구조화한다. 쿼리들은 서로 의존할 수 있다. 최종 쿼리는 “이 소스 코드를 WebAssembly로 만들면 어떤 모습인가?”가 될 것이다. 그 쿼리를 답하는 과정에서 컴파일러는 여러 하위 쿼리를 묻는다:
그리고 더 많은 것들까지! 이 모델이 전통적인 파이프라인보다 가지는 장점은 쿼리를 증분으로 답할 수 있다는 점이다. “이 변수의 타입이 뭐지?”를 물으면, 컴파일러는 그 변수를 위한 타입을 알아내기 위해 필요한 일만 한다. 그리고 쿼리는 캐시하기가 아주 좋다.
“이 소스의 CST는 어떻게 생겼나?”라는 쿼리를 한 번 답하면 그 결과를 저장해두고, 다른 누군가가 묻더라도 재사용할 수 있다. 더 좋은 점은 소스 코드가 바뀌면, 그 변경에 의존하는 쿼리만 다시 계산하면 된다는 것이다. 바뀌지 않은 코드 조각에 의존하는 쿼리는 캐시 값을 그대로 쓴다.
이는 LSP에서 특히 중요하다. 요청은 가능한 빨리 답해야 한다. 답을 증분으로 계산하면 빠르게 응답할 수 있다. 소스 코드 변경이 없는 상태에서 수많은 요청을 받을 수도 있다. 예컨대 사용자가 ‘정의로 이동’ 키를 여러 번 눌러 코드베이스를 이리저리 점프하는 상황을 상상해보라. 점프 사이에는 캐시된 쿼리 결과를 재사용하고, 소스가 바뀌는 순간에만 다시 계산한다.
앞으로 갈 길은 분명하다. 쿼리 기반 컴파일러와 언어 서버를 구현하자. 다만 우리가 하지 않을 것도 말해두자. 초점은 컴파일러와 LSP 요청을 어떻게 충족하는지에 있다. 서버를 만들다 보면 HTTP 헤더, JSON 마샬링, IO 같은 서버스러운 일이 잔뜩 필요하지만, 그런 것은 전혀 다루지 않는다. 우리는 tower-lsp 크레이트를 쓰고, 이런 것들은 그것이 처리해준다.
LSP의 프로토콜 전체도 다루지 않는다. LSP는 거대한 스펙이다. 아마 어떤 서버도 LSP가 지원하는 모든 요청을 구현하지는 못할 것이다. 우리는 의미는 있지만 작은 부분집합을 구현한다.
목표는 정해졌다. 컴파일이라는 산을 오르려면 언어 서버가 필요하다. 언어 서버를 구현하기 전에, 먼저 컴파일러 쿼리가 필요하다.
컴파일러 쿼리를 쓰기 전에, 쿼리를 실행하는 무언가가 필요하다.
무언가가 쿼리를 실행하기 전에, ‘쿼리’가 무엇인지 알아야 한다.
쿼리는 입력을 받고, 코드를 실행하고, 출력을 만든다. 함수와 구분되지 않아 보인다. 하지만 쿼리를 구분 짓는 것은 결과 캐싱과 증분 업데이트다. 이 캐싱의 부작용으로 쿼리는 순수(pure)해야 한다(즉, 부작용이 없어야 한다). 캐싱이 핵심이다. 우리는 쿼리가 ‘새로운 값’을 만든다는 것을 알 때만 실행하고 싶다. 이상적으로는, 대부분의 쿼리가 대부분의 시간에 캐시되어 있어야 한다.
쿼리들은 서로 의존하며 그래프를 이룬다. 함수의 호출 그래프와 비슷하다. 하지만 이 그래프는 함수와 달리 DAG(방향 비순환 그래프)여야 한다. 사이클이 있으면 안 된다. 쿼리가 자기 자신을 입력으로 갖게 된다면, 입력이 바뀌지 않았는지 판단하는 것이 갑자기 몹시 어려워진다.
대부분의 쿼리는 같은 로직을 따른다:
하지만 쿼리 그래프가 DAG이므로, 일부 쿼리는 입력이 없어야 한다. 이런 독립 쿼리를 입력 쿼리(input query)라고 부른다. 모든 비-입력 쿼리는 입력으로부터 값을 ‘유도’하므로, 그래프에 새 정보를 넣을 수 있는 유일한 방법은 입력 쿼리뿐이다. 입력 쿼리는 setter를 제공한다. 입력이 새 값으로 설정되면, 그 입력에 의존하는 쿼리들을 업데이트해야 한다는 사실을 알려준다.
쿼리 그래프와 입력을 관리할 무언가가 필요하다. 그 역할은 쿼리 엔진이 맡는다. 쿼리 엔진은 쿼리 실행을 구동하고, 그에 필요한 모든 상태를 저장한다. 세 가지 책임이 있다:
쿼리를 실행하는 동안 엔진은 각 쿼리의 의존성을 추적한다. 쿼리가 결과를 내면 엔진은 그것을 캐시한다. ‘행복한 경로’에서는, 엔진이 쿼리를 아예 실행할 필요가 없다고 판단하고 캐시된 값을 반환한다.
쿼리 엔진이 해주는 일이 많지만, 쿼리 엔진의 세계는 매우 깊다. 단순함을 위해, 프로덕션 쿼리 엔진에 있으면 좋은 모든 것을 다루지는 않는다:
위의 것들은 모두 과감히 덜어냈다. 엔진에는 보일러플레이트도 꽤 있다. 제대로 하고 있다면, 이런 보일러플레이트 덕분에 엔진이 어떻게 동작하는지 이해하기 쉬워진다. 실제 구현에서는 매크로 마법으로 많은 것을 감싸서 시간을 절약할 수 있다. 아니, 쿼리 엔진이 어떻게 동작하는지 이해했다면 salsa 같은 기성 솔루션을 고려해보라.
쿼리 엔진의 효율은 ‘쿼리를 실행하지 않아도 되는 시점’을 얼마나 능숙하게 판단하느냐에 달려 있다. 실행을 건너뛸 수 있는지 결정하는 알고리즘이 필요하다. 우리는 rustc에서 검증된 알고리즘인 red-green 알고리즘을 빌린다.
red-green 알고리즘은 각 쿼리에 색(빨강 또는 초록)을 붙인다. 쿼리가 초록이면 캐시 값을 재사용해도 안전하다. 쿼리가 빨강이면 다시 실행하고 캐시를 업데이트해야 한다.
노트
여기서 ‘쿼리’라고 할 때는 사실 ‘특정 인자를 가진 쿼리’를 의미한다. 예를 들어 파일 URI를 입력으로 파스 트리를 내는 cst_of(Uri) 쿼리가 있다면, 그 캐시에는 파싱한 파일 URI마다 엔트리가 하나씩 생기고, 각 엔트리는 빨강/초록일 수 있다.
모든 쿼리는 처음엔 빨강이다. 실행하면 초록으로 표시된다. 쿼리의 입력이 바뀌면 다시 빨강으로 표시한다. 엔진은 어떤 쿼리를 실행하기 전에, 그 쿼리의 의존성들의 색을 확인한다. 모두 초록이면 실행을 생략하고 캐시 값을 쓴다. 쿼리는 순수해서 입력만으로 출력이 결정되기 때문에, 이 생략이 안전하다는 것을 안다.
어떤 의존성이든 빨강이면 엔진은 쿼리를 실행한다. 이때 엔진은 아직 쿼리를 빨강으로 표시하지 않는다. 대신 새 값과 캐시 값을 비교한다. 실행 결과가 캐시 값과 같다면 쿼리를 초록으로 유지한다. 값이 다를 때만 빨강으로 표시한다.
이건 실제로는 생각보다 중요하다. 예를 들어 공백을 바꾸거나 주석을 추가하는 등 사소한 변경을 한다고 하자. 파싱 쿼리는 다시 실행해야 한다. 하지만 디슈거링에서는 같은 AST가 나올 수 있고, 그러면 디슈거링 쿼리는 초록으로 표시된다. 디슈거링에 의존하는 그 어떤 쿼리도 더 이상 실행할 필요가 없다. 남은 패스 실행을 모두 아낀 셈이다.
설명을 마쳤으니 실제 코드를 보자. QueryContext는 쿼리 엔진을 나타내며, 엔진에 필요한 모든 것을 담는다:
rsstruct QueryContext { parent: Option<QueryKey>, db: Arc<Database>, dep_graph: Arc<DepGraph>, }
각 쿼리는 QueryContext의 메서드로 존재한다. 눈치챘겠지만 우리는 쿼리를 추적하는 데 매우 관심이 있다. 추적하려면 식별이 필요하다. 엔진 전반에서 쿼리를 식별하기 위해 QueryKey 타입을 만든다. 지원하는 쿼리마다 항목이 하나씩 있는 크고 단순한 enum이다:
rsenum QueryKey { ContentOf(Uri), CstOf(Uri), NewlinesOf(Uri), // ...나머지 쿼리들 }
각 변형(variant)은 하나의 쿼리와 그 인자들을 나타낸다. content_of, cst_of, newlines_of는 모두 인자를 하나 받는데, 이는 모두 Uri로 표현되는 ‘하나의 파일’에 대해 동작하기 때문이다. LSP 기능으로 넘어가면 파일 내 특정 위치 같은 더 세밀한 인자를 받는 쿼리도 보게 될 것이다.
노트
이 enum은 단순하지만(주로 보일러플레이트 측면에서) 단점도 있다. 하지만 문제를 해결하고, 무엇이 일어나는지 이해하기 쉽다. 다만 트레이트 같은 더 세련된 해결책을 쓰면 보일러플레이트를 줄일 수 있다. rustc의 QueryConfig를 참고하라.
QueryKey는 해시맵의 키나 의존성 그래프의 정점으로 곳곳에 등장한다. 또한 QueryContext의 parent 필드로도 쓰이는데, 현재 실행 중인 쿼리의 부모를 추적한다. 실행 중에 쿼리 의존성을 추적하기 위해서다.
다음 필드 db는 데이터베이스다. 각 쿼리별 캐시를 담는다:
rsstruct Database { colors: ColorMap, revision: AtomicUsize, // Query caches content_input: DashMap<QueryKey, String>, cst_query: DashMap<QueryKey, (Cst, Vec<PellucidError>)>, newlines_query: DashMap<QueryKey, Newlines>, }
각 캐시 필드는 dashmap 크레이트의 DashMap이다. DashMap은 동시성 해시맵이다. 우리는 비동기로 요청을 처리하는 서버를 만들고 있으므로 필요하다. DashMap은 RwLock<HashMap<K, V>>로 생각해도 된다. 구현은 성능을 위해 다르지만, 이해를 돕는 좋은 정신 모델이다.
캐시와 함께 데이터베이스는 두 가지 중요한 정보를 저장한다: 컬러 맵과 리비전이다. 컬러 맵은 red-green 알고리즘에서 본 것처럼 각 쿼리의 색을 저장한다. 리비전은 입력 쿼리를 set할 때마다 증가하는 카운터다. 이전 입력을 기반으로 실행된 초록 쿼리도 여전히 최신인지 확인하게 만든다.
마지막 필드 dep_graph는 쿼리 의존성을 추적한다. 한 쿼리가 다른 쿼리를 실행할 때마다, 의존성 그래프에 기록한다:
rsstruct DepGraph { graph: RwLock<DiGraph<QueryKey, ()>>, indices: DashMap<QueryKey, NodeIndex>, }
그래프 저장은 petgraph 크레이트의 DiGraph 타입(= DAG)에 맡긴다. 의존성 그래프가 ‘있어야’ 한다는 점이 중요하지, ‘어떻게’ 구현했는지는 중요하지 않다. 관심이 있다면 코드를 파보라. 여기서는 그래프가 두 연산을 제공한다고만 알고 있자:
rsimpl DepGraph { fn add_dependency( &self, from: QueryKey, to: QueryKey ); fn dependencies( &self, key: &QueryKey ) -> Option<Vec<QueryKey>>; }
쿼리 간 의존성을 추가할 수 있고, 어떤 쿼리의 모든 의존성을 가져올 수도 있다.
우리의 쿼리 컨텍스트 전체를 사용하는 단 하나의 함수가 있다: query. 이 함수는 red-green 알고리즘을 구현하고, 쿼리 엔진의 세 책임을 모두 처리한다. 볼 게 많지만, query를 이해하면 그 위에서 쿼리를 구현하는 일은 그냥 평범한 함수를 쓰는 것과 같아진다. 먼저 시그니처부터 천천히 보자:
rsimpl QueryContext { fn query<V: PartialEq + Clone>( &self, key: QueryKey, cache: &DashMap<QueryKey, V>, producer: impl FnOnce(&Self, &QueryKey) -> V, ) -> V { todo!("Implement me please") } }
시그니처만으로도 놀라울 만큼 많은 것을 알 수 있다. 내부에서 무슨 일이 있든 우리는 결국 V를 반환한다. 제네릭 타입이므로 query 자신은 V를 만드는 방법을 모른다. V의 출처는 두 가지뿐이다: cache와 producer.
cache는 Database의 필드가 된다. 더 멋진 구현이라면 트레이트 같은 Query를 사용해 각 쿼리의 DB 필드를 자동으로 찾을 수도 있겠지만, 이 소박한 엔진에서는 수동으로 전달한다.
producer는 쿼리의 로직을 담는다. 캐시 값을 쓸 수 없어서 새 값을 _생산_해야 할 때 producer를 실행한다. producer는 QueryContext를 받아 자기 내부에서 다른 쿼리를 호출할 수 있고, QueryKey를 받아 쿼리 인자를 알 수 있다.
이제 query의 구현은 헬퍼 update_value를 정의하며 시작한다:
rslet revision = self.db.revision.load(Ordering::SeqCst); let update_value = |key: QueryKey| { todo!("we'll see what this in a sec") };
query 내부 여러 지점에서 쿼리의 값을 업데이트해야 한다고 판단할 수 있다. update_value는 그 로직을 한곳에 모아둔다. 쿼리 값 업데이트는 세 가지 작업으로 구성된다. 첫째, 의존성 그래프에 간선을 추가한다:
rsif let Some(parent) = &self.parent { self.dep_graph.add_dependency(parent.clone(), key.clone()); }
쿼리를 실행할 때(그리고 그때만) 의존성 그래프에 기록한다. 캐시 값을 쓰는 경우에는, 의존성이 이미 기록돼 있고 변하지 않았다고 믿는다. 둘째, 새 값을 만든다:
rslet value = producer( &QueryContext { parent: Some(key.clone()), db: self.db.clone(), dep_graph: self.dep_graph.clone(), }, &key, );
여기서 중요한 점은 producer를 실행할 때 QueryContext의 parent를 업데이트한다는 것이다. 실행 중인 자식 쿼리들은 key(현재 쿼리)를 자신의 parent로 보고 의존성 그래프에 기록한다. 셋째, 새 값이 캐시 값과 다른지 확인한다:
rslet old = cache.insert(key.clone(), value.clone()); if old.is_some_and(|old| old == value) { self.db.colors.mark_green(key, revision); } else { self.db.colors.mark_red(key, revision); } value
값이 바뀌지 않았다면 쿼리를 초록으로 표시하여 일을 아낀다! update_value는 새 값을 반환하며 끝난다.
다시 query로 돌아와서, update_value를 부르지 않기 위해 할 수 있는 모든 걸 시도한다:
rslet update_value = |key: QueryKey| { /* ... */ }; let Some((_, rev)) = self.db.colors.get(&key) else { return update_value(key); }; // 쿼리가 오래되었다 if rev < revision { return update_value(key); }
먼저 쿼리의 리비전을 확인한다. 컬러 맵에 엔트리가 없으면 이 쿼리는 처음 보는 것이므로 실행해야 한다. 엔트리가 있더라도 리비전이 낡았으면 실행해야 한다. 리비전을 확인한 뒤 색을 본다:
rslet color = self.try_mark_green(key.clone()); match color { Color::Green => cache .get(&key) .unwrap_or_else(|| { panic!( "Green query {:?} missing value in cache\n{:?}", key, self.db.colors ) }) .value() .clone(), Color::Red => update_value(key), }
쿼리를 초록으로 만들려고 시도한다. 성공하면 캐시 값을 쓸 수 있다. 아니면 실행할 수밖에 없다.
try_mark_green는 쿼리의 의존성을 확인한다. 의존성이 모두 초록이면 쿼리를 ‘공짜로’ 초록으로 표시한다. 중간에 빨강을 만나면 쿼리는 빨강이다:
rsfn try_mark_green(&self, key: QueryKey) -> Color { let revision = self.db.revision.load(Ordering::SeqCst); // 그래프에 의존성이 없으면 쿼리를 실행해야 한다고 가정 let Some(deps) = self.dep_graph.dependencies(&key) else { return Color::Red; }; for dep in deps { match self.db.colors.get(&key) { Some((Color::Green, rev)) if revision == rev => continue, Some((Color::Red, _)) => return Color::Red, _ => todo!("a single case"), } } // 모든 의존성을 초록으로 만들었다면, 이 노드도 초록으로 self.db.colors.mark_green(key, revision); Color::Green }
그래프에 기록된 의존성들을 순회하면서, 의존성 중 하나라도 빨강이면 빨강을 반환한다. todo!로 표시된 케이스는 의존 쿼리가 컬러 맵에 없거나 리비전이 낡은 경우다. 이때는 그 의존성을 재귀적으로 초록으로 만들려고 시도한다:
rsif self.try_mark_green(dep.clone()) != Color::Green { self.run_query(dep); // 방금 쿼리를 실행했으니 리비전이 최신임을 확신할 수 있다. match self.db.colors.get(&key) { Some((Color::Green, _)) => continue, Some((Color::Red, _)) => return Color::Red, None => unreachable!("we just ran the query"), } }
성공하면 의존성은 초록이다. 실패하면, 쿼리를 실행해보는 것으로 여전히 초록이 될 수 있는지 확인한다. (생산된 값이 캐시 값과 같을 수 있기 때문이다.) run_query는 QueryKey에 따라 적절한 쿼리를 호출하고 결과는 버리는 헬퍼다.
이것이 query가 하는 전부다. 우리의 쿼리 엔진은 소박하지만 완전하다. 이제 쿼리 엔진이 어떻게 동작하는지 이해했다. 쿼리를 쓰기만 하면 증분으로 쿼리를 실행하고, 심지어 코드도 컴파일한다!
쿼리에 대해 배우는 건 재밌지만, 사실 컴파일러와 직접 관련은 없었다. 쿼리는 무엇이든 계산할 수 있다: UI, 게임 상태, ETL 파이프라인 등. 범용 증분 계산 팬들은 댓글에서 목소리를 내달라. 그들에게는 기쁜 일이겠지만, 우리는 피로 물든 컴파일러를 만들러 왔다. 이제 코드 컴파일을 하는 쿼리에 대해 이야기하자.
최소한 컴파일러 각 패스(파싱, 타입 추론, 클로저 변환 등)마다 하나의 쿼리가 필요하다. 각 패스 쿼리는 비슷한 형태를 가진다. 각 패스는 진입점 역할을 하는 최상위 함수를 갖고 있다(마치 쿼리용으로 설계된 것처럼). 우리는 그 함수를 query 호출로 감싸고, 필요한 의존 쿼리를 수행한다.
아이디어를 잡기 위해 파서 쿼리 cst_of를 예로 보자. cst_of는 전체 파일의 파스 트리를 만든다. 먼저 QueryKey에 엔트리를 추가한다:
rsenum QueryKey { // ... CstOf(Uri), // ... }
다음으로 데이터베이스에 캐시를 저장할 cst_query 필드를 추가한다:
rsstruct Database { // ... cst_query: DashMap<QueryKey, (Cst, Vec<PellucidError>)>, // ... }
두 가지 출력(CST와 에러)을 저장한다. PellucidError는 각 패스에서 생길 수 있는 에러 변형을 가진 큰 enum이다. 언어 이름이 Pellucid라서 그렇다(지금까지 말 안 했을 수도 있겠다). 이 두 변경으로 cst_of 쿼리를 구현할 준비가 됐다:
rsimpl QueryContext { fn cst_of( &self, uri: Uri ) -> (Cst, Vec<PellucidError>) { self.query( QueryKey::CstOf(uri), &self.db.cst_query, |this, key| { todo!("Implement me") }) } }
아까 말한 보일러플레이트가 보인다. 실제 cst_of 로직은 producer로 넘기는 클로저 안에 있다:
rslet QueryKey::CstOf(uri) = key else { unreachable!("cst") }; let content = this.content_of(uri.clone()); let (root, errors) = parser_base::parse(&content); ( root, errors.into_iter().map(PellucidError::Parser).collect(), )
쿼리 엔진을 만드는 데 많은 노력을 들였지만, 이 코드는 매우 짧고 쿼리 관련 걱정도 거의 없다. 쿼리스러운 부분은 시작에 있다. QueryKey에서 인자를 다시 꺼내야 한다. 실패할 수 없다. 세 줄 위에서 늘 QueryKey::CstOf를 넘기니까. 하지만 컴파일러를 설득해야 해서 unreachable!가 필요하다. enum 기반 설계의 보일러플레이트다. 더 고급 설계라면 막을 수 있지만, 우리는 어떻게든 버텨야 한다.
나머지 로직은 순수하다. content_of로 파일 내용을 구한다. 내부적으로는 또 다른 쿼리여서 cst_of가 content_of에 의존한다는 사실이 추적되지만, 여기서는 신경 쓸 필요가 없다. 내용을 parse에 넘기고 결과를 반환한다. 파서는 ParseError 타입으로 에러를 내므로 PellucidError::Parser로 감싼다.
다른 패스들도 이 패턴을 따른다. 호출하는 함수는 달라도 공식은 같다. 쿼리 키를 만들고, DB 필드를 추가하고, 함수를 query로 감싼다.
하나 더 빠르게 보자. 이번엔 lowering이 만든 IR을 최적화하는 simple_ir_of를 보겠다. QueryKey::SimpleIrOf(Uri) 변형을 만들고 DB에 simple_ir_query: DashMap<QueryKey, Option<lowering_base::IR>> 필드를 추가한다. simple_ir_of는 query를 한 번 호출하는 것으로 끝난다:
rsfn simple_ir_of( &self, uri: Uri ) -> Option<lowering_base::IR> { self.query( QueryKey::SimpleIrOf(uri), &self.db.simple_ir_query, |this, key| { let QueryKey::SimpleIrOf(uri) = key else { unreachable!() }; let lower = this.ir_of(uri.clone())?; let simple_ir = simplify_base::simplify(lower.ir); Some(simple_ir) }) }
같은 패턴이다:
ir_of 쿼리에서 입력을 얻는다이 단계에서는 탄력성이 사라진다. 여기서 생기는 에러는 내부 컴파일러 에러(ICE)다. 하지만 서버가 하드 크래시하면 좋은 경험이 아니다. 우리는 잘못된 입력에도 쿼리를 호출할 수 있길 원한다(기저 패스는 그렇지 못하더라도). 이를 위해 쿼리 결과를 Option으로 감싼다. 프런트엔드에서 에러가 있으면 타입 추론 이후 패스는 호출하지 않는다.
다른 패스들의 쿼리는 전체 소스 코드에서 볼 수 있다. wasm_of는 최상위 아이템이 없어서 조금 다르지만, 나머지는 이 패턴을 따른다.
단 하나의 예외가 있는데, 유일한 입력 쿼리 content_of다. cst_of에서 봤듯이, 파일 URI의 소스 텍스트를 제공한다. 입력 쿼리이므로 set_content 메서드가 있다:
rsfn set_content(&self, uri: Uri, content: String) { let key = QueryKey::ContentOf(uri); self.content_input.insert(key.clone(), content); let old_revision = self.revision.fetch_add(1, Ordering::SeqCst); self.colors.mark_red(key, old_revision + 1); }
콘텐츠를 빨강으로 표시하고 리비전을 증가시켜, 콘텐츠에 의존하는 쿼리들을 업데이트해야 함을 엔진에 알린다. 쿼리의 다른 절반인 content_of는 URI의 콘텐츠를 반환한다:
rsfn content_of( &self, uri: Uri ) -> String { let key = QueryKey::ContentOf(uri.clone()); if let Some(parent) = &self.parent { self.dep_graph .add_dependency( parent.clone(), key.clone()); } self.db.colors.mark_green( key, self.db .revision .load(Ordering::SeqCst)); self .db .content_input .get(&QueryKey::ContentOf(uri)) .map(|r| r.value().clone()) .expect("Uri was queried with unset value") }
content_of에는 query 호출이 없다. 의존성도 없고 캐시를 업데이트할 필요도 없기 때문이다. 하지만 부모 쿼리를 위해 의존성 추적은 하고 싶다. 그래서 부모가 있으면 의존성을 기록하고, 항상 초록으로 표시한 뒤 캐시에서 값을 직접 꺼낸다.
컴파일러 패스는 쿼리 작성 감을 잡게 해줬다. 이제 IDE 기능을 수행하는 쿼리를 쓰며 실제로 LSP를 구현할 것이다. IDE의 기본 중 기본, 진단(Diagnostics)부터 시작하자. 진단은 우리가 코드를 틀리게 쳤을 때 에디터에 뜨는 빨간 물결선 같은 것이다.
각 프런트엔드 패스는 에러 목록을 만든다. 하지만 그 에러들은 아직 ‘진단’이 아니다. 여기서 진단은 LSP의 diagnostic 개념을 뜻한다. LSP 진단은 소스 파일의 구간(span)과 사람이 읽을 수 있는 메시지다. tower-lsp는 이를 위한 타입 Diagnostic를 제공한다. 에러를 그 형태로 망치질해 맞추면, 응답에 실어 보낼 수 있고 나머지는 tower-lsp가 처리한다.
모든 에러를 모아 진단으로 바꾸는 새 쿼리 diagnostics_of를 만든다:
rsfn diagnostics_of(&self, uri: Uri) -> Vec<Diagnostic> { self.query( QueryKey::DiagnosticsOf(uri), &self.db.diagnostics_query, |this, key| { let QueryKey::DiagnosticsOf(uri) = key else { unreachable!() }; let newlines = this.newlines_of(uri.clone()); this .errors(uri.clone()) .map(|err| match err { todo!("a truly gargantuan match") }) .collect() }) }
에러를 모으는 일은 흔하므로 헬퍼 errors를 만든다:
rsfn errors( &self, uri: Uri ) -> impl Iterator<Item = PellucidError> { let types = self.types_of(uri.clone()); let nameres = self.nameresolve_of(uri.clone()); let desugar = self.desugar_of(uri.clone()); let (_, parse_errors) = self.cst_of(uri.clone()); types .errors .into_iter() .chain(nameres.errors) .chain(desugar.errors) .chain(parse_errors) }
각 에러를 매핑해 진단으로 변환한다. 변환은 거대한 match 표현식으로 수행되며, 각 케이스가 사람이 읽을 수 있는 문자열과 에러에서 얻은 span을 만든다. 모든 케이스를 다루진 않겠다. 너무 기계적이라 금방 지루해진다. 타입 에러를 어떻게 처리하는지만 보면 핵심 아이디어를 알 수 있다:
rsPellucidError::Types(types) => { let desugar = this.desugar_of(uri.clone()); let range = newlines .lsp_range_for(desugar.ast_to_cst[&types.node].ptr.text_range().into()) .expect("error span outside range"); Diagnostic::new_simple( range, todo!("match on `types.mark` to create a string") ) }
타입 에러는 NodeId로 보고된다. 클라이언트에겐 무의미한 개념이다. 디슈거링의 ast_to_cst를 사용해 NodeId를 CST 노드로 바꾼다. CST 노드는 바이트 범위를 가지지만, LSP는 줄/열 범위를 사용한다.
바이트 범위를 줄/열 범위로 바꿔야 한다. newlines_of가 이를 돕는다. 소스 파일을 처리해 모든 줄바꿈을 찾고, 바이트 ↔ 줄/열 매핑을 만든다. 만들어진 뒤에는 lsp_range_for로 바이트 범위에 대한 줄/열 범위를 얻기만 하면 된다.
범위를 얻으면 new_simple로 진단을 만든다. LSP 진단은 많은 기능을 지원하지만 우리는 거의 쓰지 않는다. new_simple은 Diagnostic의 모든 필드에 합리적 기본값을 넣어주는 편의 생성자다. 그래서 우리는 범위와 메시지 문자열만 제공하면 된다. 메시지는 types.mark를 매칭해 만든다:
rsmatch types.mark { TypeError::AppExpectedFun { inferred_ty, expected_fun_ty, } => format!( "types: Expected this to be a function {} but it has type {}", prettyprint_ty(&expected_fun_ty), prettyprint_ty(&inferred_ty) ), // 다른 케이스들... }
AppExpectedFun은 예쁘게 출력한 타입으로 적절한 메시지를 만든다. pretty printing은 괄호 추가, 숫자 ID를 그리스 문자로 바꾸기 등을 처리한다.
노트
정보 제공을 위해 진단에 “types:” 태그를 붙인다. 플레이그라운드에서 진단이 어디서 왔는지 보기 쉽게 하기 위해서다. 실제 구현에서는 꼭 필요하진 않다(원하면 해도 된다!).
다른 케이스들도 비슷하니 넘어가자. 이제 이 쿼리를 요청 시 실행할 서버가 필요하다.
diagnostics 쿼리를 언제 실행할지 쿼리 엔진에 알려줄 무언가가 필요하다. 그게 언어 서버다. 여기서 tower-lsp가 해주는 일이 얼마나 많은지 과장해도 부족하다. 우리는 그저 LanguageServer 트레이트 구현만 제공하면 된다.
심지어 전부 구현할 필요도 없다. 트레이트를 구현하려면 타입이 필요하다:
rsstruct PellucidLsp { client: Client, database: Arc<Database>, dep_graph: Arc<DepGraph>, }
PellucidLsp는 서버를 나타낸다. 여기 저장된 것은 서버의 생애와 함께한다. database와 dep_graph를 담아 쿼리 상태가 서버가 살아있는 동안 유지되게 한다.
Client는 tower-lsp 타입이다. 서버에 연결된 클라이언트를 나타내며, 요청 응답과 별개로 클라이언트에 정보를 보낼 수 있게 한다. 우리는 이 구조체에 대해 LanguageServer를 구현한다:
rsimpl LanguageServer for PellucidLsp { async fn initialize(&self, _: InitializeParams) -> Result<InitializeResult> { ... } async fn shutdown(&self) -> Result<()> { ... } }
LanguageServer는 구현체에 두 메서드만 요구한다: initialize와 shutdown. initialize는 클라이언트가 서버에 연결할 때 보내진다. 워크스페이스 디렉터리, 클라이언트가 지원하는 기능 같은 파라미터가 포함된다. 서버도 이에 응답하여 자신이 지원하는 기능, 문자 인코딩 등을 제공한다. 즉 initialize는 핸드셰이크로, 클라이언트와 서버가 공통으로 지원하는 LSP 부분집합에 합의하게 한다. shutdown은 콜백처럼 동작하여 서버가 종료되기 전 필요한 정리를 할 수 있게 한다.
구현은 레포에서 볼 수 있다. 이제 첫 LSP 요청: 진단을 구현할 수 있다. 클라이언트는 파일에 대한 진단을 원할 때 진단 요청을 보낸다. 코드를 보자:
rsasync fn diagnostic( &self, params: tower_lsp_server::lsp_types::DocumentDiagnosticParams, ) -> Result<DocumentDiagnosticReportResult> { let ctx = self.root_query_context(); let diags = ctx.diagnostics_of(params.text_document.uri); if diags.is_empty() { return Ok(DocumentDiagnosticReportResult::Report( RelatedUnchangedDocumentDiagnosticReport { related_documents: None, unchanged_document_diagnostic_report: UnchangedDocumentDiagnosticReport { result_id: "unchanged".to_string(), }, } .into(), )); } Ok(DocumentDiagnosticReportResult::Report( RelatedFullDocumentDiagnosticReport { related_documents: None, full_document_diagnostic_report: FullDocumentDiagnosticReport { result_id: None, items: diags, }, } .into(), )) }
두 줄만으로 쿼리로 진단 리스트를 만든다. root_query_context는 parent가 없는 QueryContext를 만드는 헬퍼다. 나머지는 진단을 LSP 응답 형태로 두드려 맞추는 코드다.
쿼리에서 나온 진단은 DocumentDiagnosticReportResult가 되어야 한다. 장황한 이름이지만, 결국 진단 리스트를 JSON 메시지 안에 넣는 것이다. 이는 부분 진단, 관련 파일, 요청 간 지속성 등 아직 지원하지 않는 화려한 기능을 위해 존재한다.
우리는 그 중 하나를 사용한다. 진단 리스트가 비어 있으면, 빈 리스트를 포함한 리포트를 보내는 대신 ‘변경 없음(unchanged)’ 리포트를 보낸다. 그러면 클라이언트는 아무 것도 할 필요가 없음을 안다.
이제 클라이언트는 원할 때 진단을 요청할 수 있다. 하지만 이 워크플로는 썩 이상적이지 않다. 우리는 컴파일러인데, 왜 클라이언트가 뭔가 잘못됐는지 물어봐야 하지? 우리가 이미 알고 있잖아!
diagnostic 요청도 진단을 제공하는 한 방법이지만 주된 방법은 아니다. 진단은 주로 두 LSP 요청 did_open과 did_change를 통해 보고된다. 둘 다 응답이 필요 없는 서버 알림(notification)이다.
did_open은 클라이언트가 파일을 열었음을 알린다.did_change는 열려 있는 파일의 내용이 바뀌었음을 알린다.클라이언트가 파일을 열면, 그 내용을 DB에 기록한다:
rsasync fn did_open(&self, params: DidOpenTextDocumentParams) { let uri = params.text_document.uri; self .database .set_content(uri.clone(), params.text_document.text); let ctx = self.root_query_context(); let diags = ctx.diagnostics_of(uri.clone()); self .client .publish_diagnostics(uri, diags, Some(params.text_document.version)) .await; }
마지막 줄에서 Client를 통해 “응답”을 슬쩍 보낸다는 것을 볼 수 있다. publish_diagnostics는 클라이언트가 요청하기를 기다리지 않고 진단을 푸시할 수 있게 한다. 파일이 바뀔 때도 마찬가지다:
rsasync fn did_change( &self, params: DidChangeTextDocumentParams ) { let uri = params.text_document.uri; let ctx = self.root_query_context(); let newlines = ctx.newlines_of(uri.clone()); let mut content = ctx.content_of(uri.clone()); // params를 사용해 content 업데이트... self.database.set_content(uri.clone(), content); let diags = ctx.diagnostics_of(uri.clone()); self .client .publish_diagnostics(uri, diags, Some(params.text_document.version)) .await; }
변경을 실제로 적용하는 부분은 생략했다. 우리 플레이그라운드는 항상 파일 전체를 업데이트하지만, did_change는 부분 업데이트도 지원한다. 즉 구현도 부분 업데이트를 지원해야 한다. 하지만 지금은 그게 핵심이 아니다. 여기서 집중할 흐름은 이거다:
쿼리 엔진이 새 콘텐츠를 증분 컴파일해서 진단을 만든다. 이 두 경로가 진단을 클라이언트에 전달하는 주된 길이며, 파일 변경 즉시 발생하므로 선호된다.
진단으로 LSP 요청 구현을 맛봤다. 이제 진짜 멋진 것을 만들 도구가 갖춰졌다. 다음 다섯 가지 LSP 기능을 구현하자:
이 모든 요청은 두 핵심 쿼리에 의존한다: syntax_node_starting_at와 ast_node_of. 이 둘은 함께 언어 서버의 심장을 이룬다. 그들이 얼마나 많은 쿼리에 의존하는지 보면 알 수 있다.
syntax_node_starting_at는 줄/열 위치를 CST 노드로 바꾸고, ast_node_of는 CST 노드를 AST 노드로 바꾼다. 우리는 항상 둘을 연달아 호출하지만, 캐싱을 위해 쿼리로는 분리한다. 여러 줄/열 위치가 결국 같은 구문 노드를 참조할 수 있다. ast_node_of를 별도 쿼리로 두면, 그 구문 노드에 대해 AST 노드를 한 번만 캐시하면 되지 줄/열마다 캐시하지 않아도 된다.
syntax_node_starting_at는 새 QueryKey 엔트리로 시작한다:
rsenum QueryKey { // ... NodeStartingAt(Uri, Position), // ... }
이 쿼리는 파일 전체를 처리하지 않는다. 파일과 그 안의 위치(= LSP가 줄/열을 표현하는 방식)를 받는다. 쿼리 캐시에도 드디어 여러 엔트리가 생긴다. DB 필드가 어떻게 생겼는지는 짐작할 수 있을 것이다. 쿼리부터 보자:
rspub fn syntax_node_starting_at( &self, uri: Uri, cursor: Position ) -> Option<SyntaxNodeHandle> { self.query( QueryKey::NodeStartingAt(uri, cursor), &self.db.node_starting_at_query, |this, key| { todo!("Implement me") }, ) }
이제 producer에서 줄/열을 커서 아래의 CST 노드 핸들로 바꾼다:
rslet QueryKey::NodeStartingAt(uri, cursor) = key else { unreachable!("starting") }; let (green, _) = this.cst_of(uri.clone()); let cst = SyntaxNode::<Lang>::new_root(green.clone()); let newlines = this.newlines_of(uri.clone()); let byte: u32 = newlines .byte_of(cursor.line, cursor.character)? .try_into() .unwrap();
cst로 트리를 순회할 수 있다. newlines_of는 LSP 줄/열을 바이트 오프셋으로 바꾸는 것도 처리한다(실은 양방향 다 한다. 어제 여기 있었는데 양방향이었다). byte로 cst에서 토큰을 찾는다:
rslet token = cst.token_at_offset(TextSize::from(byte));
이 무심한 한 줄은 내부적으로 꽤 많은 일을 한다. 트리를 샅샅이 뒤져 오프셋의 토큰을 찾는다. 다행히 TOML에 rowan = 0.16을 넣었으니 잘 굴러간다. 하지만 아직 끝이 아니다. 오프셋이 두 토큰 사이에 딱 걸칠 수도 있다(사용자들은 별 생각을 다 한다). 그런 경우 의미 있는 토큰을 고른다:
rslet token = match token { parser_base::rowan::TokenAtOffset::None => return None, parser_base::rowan::TokenAtOffset::Single(token) => token, // 공백에서 벗어나도록 편향: 우리가 원하는 게 아닐 가능성이 크다. // 식별자 쪽으로 편향: 의미적으로 더 흥미로울 가능성이 크다. // 그 외에는 왼쪽을 선택 parser_base::rowan::TokenAtOffset::Between(left, right) => { match (left.kind(), right.kind()) { (_, Syntax::Whitespaces) | (Syntax::Identifier, _) => left, (Syntax::Whitespaces, _) | (_, Syntax::Identifier) => right, _ => left, } } };
둘 중 하나를 고를 수 있을 때는 식별자를 선호하고 공백은 기피한다. 공백은 사용자가 상호작용하길 원하는 대상일 가능성이 거의 없다. 의미적 정보도 없다. 반대로 식별자는 사용자가 찾는 바로 그 토큰이다. 왜 그런지, 우리가 하는 일을 떠올려보면 금방 납득한다:
정말 다른 토큰이 왜 필요한지 의문이 들 정도다. 의미적 보상은 식별자에 있다. 마지막으로 토큰을 구문 노드 핸들로 바꾼다:
rslet node = token.parent()?; Some(SyntaxNodeHandle { root: green, ptr: SyntaxNodePtr::new(&node), })
토큰을 감싸는 노드를 원하므로 한 단계 위로 올라간다. 노드를 핸들로 바꿔 결과로 반환한다.
노트
디슈거링 때도 말했듯, rowan은 빠른 순회를 위해 내부적으로 포인터를 쓰는데 장기 저장에는 안전하지 않다. 그래서 우리는 CST의 루트 노드와 트리 안의 인덱스를 저장한다. 혼란스럽게도 그 인덱스가 SyntaxNodePtr이다.
ast_node_of는 핸들과 파일 URI를 받는다. 쿼리 키에서 볼 수 있다:
rsenum QueryKey { AstNodeOf(Uri, SyntaxNodeHandle), }
이 둘을 사용해 typed AST에서 대응하는 AST 노드를 찾는다:
rsfn ast_node_of( &self, uri: Uri, node: SyntaxNodeHandle ) -> Option<Ast<TypedVar>> { self.query( QueryKey::AstNodeOf(uri.clone(), node), &self.db.ast_node_query, |this, key| { todo!("Implement me") }) }
반환 타입은 Ast<TypedVar>다. 엄밀히는 desugar/nameres/types 어느 Ast든 반환할 수 있다. 하지만 types를 선택했다. 이름 해결과 타입 둘 다 들어 있어 정보가 가장 많기 때문이다. 다른 Ast가 필요하면 NodeId를 통해 언제든 찾을 수 있다. 구현은 꽤 짧다:
rslet QueryKey::AstNodeOf(uri, node) = key else { unreachable!("ast"); }; let desugar = this.desugar_of(uri.clone()); let id = desugar .ast_to_cst .iter() .find_map(|(id, sync_node)| (sync_node == node).then_some(id))?; let types = this.types_of(uri.clone()); types.ast.find(*id).cloned()
ast_to_cst를 순회하며 sync_node와 같은 엔트리를 찾고 대응하는 NodeId를 얻는다. 그 id로 typed 트리에서 AST 노드를 찾는다. find는 DFS로 트리를 순회해 매칭되는 노드를 반환한다(있다면). 이것으로 두 핵심 쿼리가 완성됐다. 이제 LSP 기능을 만들자.
변수를 읽다가 “잠깐, 이게 뭐였지?” 싶었던 적이 있는가? 정의로 이동이 답을 준다. 식별자 위에 커서를 두고 정의로 이동을 하면, 그 식별자의 정의 위치로 점프한다. 우리 언어에서는 let 바인딩 또는 함수 파라미터가 그 정의다.
LSP 기능이므로 요청이 있다:
rsasync fn goto_definition( &self, params: GotoDefinitionParams, ) -> Result<Option<GotoDefinitionResponse>> { let ctx = self.root_query_context(); let uri = params.text_document_position_params.text_document.uri; let Some(range) = ctx.definition_of( uri.clone(), params.text_document_position_params.position ) else { return Ok(None); }; Ok(Some(GotoDefinitionResponse::Scalar( tower_lsp_server::lsp_types::Location { // 여러 파일이 생기면 달라질 수 있지만, // 지금은 uri는 항상 같다. uri, range, }, ))) }
LSP는 소스 코드 범위만 다룬다. 요청은 커서의 줄/열을 받고, 응답은 정의의 범위를 제공하며, 표시 책임은 클라이언트가 진다. definition_of 쿼리가 이를 정의 범위로 바꾼다:
rsfn definition_of( &self, uri: Uri, cursor: Position ) -> Option<LspRange> { self.query( QueryKey::DefinitionOf(uri.clone(), cursor), &self.db.definition_query, |this, key| { todo!("Implement me") }, ) }
definition_of는 커서 위치의 AST 노드를 가져오는 것으로 시작한다. 우리의 콤보 쿼리를 사용한다:
rslet QueryKey::DefinitionOf(uri, cursor) = key else { unreachable!(); }; let syntax = this.syntax_node_starting_at(uri.clone(), cursor)?; let ast_node = this.ast_node_of(uri.clone(), syntax)?; let Ast::Var(node_id, var) = ast_node else { return None; };
커서가 변수 위에 있지 않으면 정의로 이동할 게 없으니 끝이다. 변수의 정의를 찾으려면, 그 변수를 정의하는 가장 가까운 AST 노드를 찾아야 한다. 이름 해결이 필요해 보인다:
rslet ast = this.nameresolve_of(uri.clone()).ast; let binder_id = ast .parents_of(node_id)? .into_iter() .find_map(|ast| match ast { Ast::Fun(node_id, bind, _) if bind == &var.0 => Some(node_id), _ => None, })?;
변수의 부모들을 따라 올라가며 그것을 정의하는 함수를 찾는다. 이름 해결된 AST를 사용하므로 변수는 어딘가에서 정의되어 있음을 확신할 수 있다. 못 찾는다면 이론상 불가능하지만, 어쨌든 None을 반환한다. 정의 AST 노드를 얻었으니 CST 노드로 되돌린다:
rslet ast_to_cst = this.desugar_of(uri.clone()).ast_to_cst; let binder_node = ast_to_cst.get(binder_id)?; let root = SyntaxNode::new_root(binder_node.root.clone()); let syntax = binder_node.ptr.to_node(&root); let mut binder = syntax; if binder.kind() == Syntax::Fun { binder = binder.first_child_by_kind(&|kind| kind == Syntax::FunBinder)?; }
부모가 Fun 노드인 경우 binder까지 내려간다. 그러면 | 대신 식별자로 점프해서 더 좋은 UX가 된다.
노트
let 바인딩에는 이 작업이 필요 없다. let의 함수 노드는 이미 LetBinder로 매핑되기 때문이다.
마지막으로 CST 노드를 LSP 범위로 바꾼다:
rslet range_node = binder.first_token()?; let newlines = this.newlines_of(uri.clone()); let range = newlines.lsp_range_for(range_node.text_range().into())?; Some(range)
이로써 정의로 이동을 구현했다.
호버는 코드의 흥미로운 부분에 마우스를 올리면 툴팁을 보여준다. 다소 추상적인 정의지만, 호버가 커버하는 영역이 넓기 때문이다. 여기서는 식별자에 마우스를 올리면 그 타입을 보여주는 것으로 하자. LSP hover 요청은 짧다:
rsasync fn hover( &self, params: HoverParams ) -> Result<Option<tower_lsp_server::lsp_types::Hover>> { let uri = params.text_document_position_params.text_document.uri; let position = params.text_document_position_params.position; let ctx = self.root_query_context(); Ok(ctx.hover_of(uri.clone(), position.clone())) }
핵심은 hover_of에 있다. 이제는 쿼리 모양에 익숙해졌으니 로직으로 바로 들어가자(사실은 query 호출로 감싸져 있다고 상상하면 된다). hover_of는 goto def처럼 syntax_node_starting_at로 시작한다:
rslet QueryKey::HoverOf(uri, position) = key else { unreachable!("hover") }; let cst = this.syntax_node_starting_at(uri.clone(), position.clone())?; let syntax = SyntaxNode::<Lang>::new_root(cst.root.clone()); // 나중에 올바른 hover 범위를 얻기 위해 필요하므로, 섀도잉하지 않으려고 따로 둔다. let cursor_node = cst.ptr.to_node(&syntax); // 변수(표현식 또는 바인드 위치) 위에서만 hover를 보여준다. let node = match cursor_node.kind() { Syntax::Var | Syntax::LetBinder => cursor_node.clone(), Syntax::FunBinder => cursor_node.parent()?, _ => return None, };
goto def와 달리, AST 노드로 바꾸기 전에 구문 노드를 조정한다. 호버는 어디에나 붙을 수 있지만, 우리는 호버를 만들 때 클라이언트에 툴팁이 표시될 범위를 준다. 그 범위는 ‘커서 아래 노드’여야 하므로, 수정하기 전에 복사해둔다.
다음으로 호버 가능한 대상인지 확인한다. 변수(바인드 여부 무관). 함수 파라미터라면 부모 노드를 원한다. FunBinder는 AST로 매핑하지 않고 Fun만 매핑한다. 이제 AST 노드를 찾는다:
rslet ast_node = this.ast_node_of( uri.clone(), SyntaxNodeHandle { root: cst.root.clone(), ptr: SyntaxNodePtr::new(&node), }, )?; let ty = match &ast_node { Ast::Var(_, typed_var) => &typed_var.1, Ast::Fun(_, typed_var, _) => &typed_var.1, _ => return None, };
AST 노드는 툴팁의 내용인 타입을 준다. 마지막으로 CST 노드와 타입으로 Hover를 만든다:
rslet newlines = self.newlines_of(uri.clone()); let range = newlines.lsp_range_for(cursor_node.text_range().into()); Some(Hover { range, contents: HoverContents::Scalar(MarkedString::LanguageString(LanguageString { language: "pellucid".to_string(), value: prettyprint_ty(ty), })), })
이 많은 레이어로 감싸 LSP에 ‘평범한 문자열’임을 알려준다. 또한 언어가 pellucid임을 명시하지만, 오늘 생긴 언어라 에디터들이 신경 쓸지는 모르겠다 😔.
참조는 정의로 이동의 반대다. 변수를 주면, 코드에서 그것이 참조되는 모든 곳을 보여준다. 요청은 또 쿼리의 얇은 래퍼다:
rsasync fn references( &self, params: ReferenceParams ) -> Result<Option<Vec<Location>>> { let ctx = self.root_query_context(); let uri = params.text_document_position.text_document.uri; let cursor = params.text_document_position.position; Ok(ctx.references_of(uri, cursor)) }
Location은 URI와 줄/열의 쌍이다. references_of는 변수인지 확인하는 것으로 시작한다:
rslet QueryKey::ReferencesOf(uri, cursor) = key else { unreachable!() }; let sync_node = this.syntax_node_starting_at(uri.clone(), cursor.clone())?; let ast_node = this.ast_node_of(uri.clone(), sync_node)?; let var = match ast_node { Ast::Var(_, var) | Ast::Fun(_, var, _) => var, _ => return None, };
AST에서 var의 모든 참조를 얻는다:
rslet ast = self.nameresolve_of(uri.clone()).ast; let vars = ast.var_reference(&var.0);
var_reference라… 약간 ‘부엉이 그리기 튜토리얼’ 느낌이 난다. var_reference는 find처럼 DFS를 하지만, 첫 번째만 반환하는 대신 Var를 언급하는 모든 노드를 반환한다. 이제 우리는 모든 발생 지점을 얻었고, 쿼리를 마치려면 위치 목록으로 바꿔야 한다:
rslet ast_to_cst = self.desugar_of(uri.clone()).ast_to_cst; let newlines = self.newlines_of(uri.clone()); let references = vars .into_iter() .filter_map(|var| { let id = var.id(); let sync_node = ast_to_cst.get(&id)?; let root = SyntaxNode::new_root(sync_node.root.clone()); let mut syntax = sync_node.ptr.to_node(&root); if syntax.kind() == Syntax::Fun { syntax = syntax.first_child_by_kind(&|kind| kind == Syntax::FunBinder)?; } let token = syntax.first_token()?; let range = newlines.lsp_range_for(token.text_range().into())?; Some(Location { uri: uri.clone(), range, }) }) .collect(); Some(references)
hover에서 본 로직과 같다. CST 노드를 찾고, span을 얻고, 줄/열로 바꾼다. 이번엔 변수마다 한 번씩 한다. 솔직히 이건 쿼리로 만들었어야 했다. 어쩌다 보니… 뭐, 어쨌든 끝이다.
이름 바꾸기는 변수가 등장하는 모든 곳에서 이름을 바꾼다. 잠깐, “등장하는 모든 곳”은 익숙한 표현 아닌가? 실제로 요청을 보면 직감이 맞다:
rsasync fn rename( &self, params: RenameParams ) -> Result<Option<WorkspaceEdit>> { let ctx = self.root_query_context(); let uri = params.text_document_position.text_document.uri; let cursor = params.text_document_position.position; let new_name = params.new_name; let edits = ctx.references_of(uri, cursor).map(|locs| { let mut changes = HashMap::default(); for loc in locs { let edits = changes.entry(loc.uri).or_insert(vec![]); edits.push(TextEdit { range: loc.range, new_text: new_name.clone(), }); } WorkspaceEdit { changes: Some(changes), ..Default::default() } }); Ok(edits) }
좋다! rename을 위한 새 쿼리가 필요 없다. references_of가 이미 변수의 모든 발생 지점을 준다. 다만 위치 리스트 대신 WorkspaceEdit를 반환한다. 서버는 파일을 직접 수정하지 않는다. 사용자가 상호작용하는 동안 파일을 수정할 위험을 감수할 수 없다. 대신 “이런 변경을 해달라”는 JSON을 만들어 클라이언트에 보내고, 적용은 클라이언트가 한다.
rename에서는 각 위치 loc.range의 텍스트를 new_name으로 바꾸면 된다. 그게 전부다.
마지막이자 가장 중요한 기능, 자동 완성이다. 개인적으로 IDE 경험에서 가장 눈에 띄는 기능이라고 생각한다. 사용자가 타이핑할 때 코드베이스를 뒤져 사용자가 의도했을 법한 것을 옵션으로 보여주고, 키 입력을 줄여준다. 컴파일러가 내가 뭘 원하는지 이해하고 쟁반에 올려주는 느낌은 꽤 짜릿하다.
자동 완성은 매우 화려해질 수 있다. 커서 위치에 들어가야 할 타입을 추론해서 그 타입에 맞는 옵션만 보여줄 수도 있고, 사용자가 접근 중인 객체 타입을 추적해 그 객체의 메서드만 보여줄 수도 있다. 타입 정보를 더 많이 얻을수록 추천을 더 잘 다듬을 수 있다. 하지만 그런 내용은 다음 튜토리얼로 미루자.
여기서 자동 완성은 ‘부분적으로 완성된 식별자’를 받는다. 우리는 그 식별자 위치에서 스코프에 있는 모든 변수를 찾아 완성 후보로 제공한다. 다른 요청처럼 쿼리를 호출해 응답으로 돌려준다:
rsasync fn completion( &self, params: CompletionParams ) -> Result<Option<CompletionResponse>> { let ctx = self.root_query_context(); let completions = ctx.completion_of( params.text_document_position.text_document.uri, params.text_document_position.position, ); Ok(completions) }
쿼리 엔진을 만들었으면 끝까지 써야지! completion_of는 곧바로 scope_at이라는 다른 쿼리를 호출한다:
rslet QueryKey::CompletionOf(uri, cursor) = key else { unreachable!(); }; let scope = this .scope_at(uri.clone(), *cursor)? .into_iter() .collect::<Vec<_>>();
자동 완성의 미묘한 점은 커서 위치에서 스코프에 있는 식별자만 추천해야 한다는 것이다. 프로그램 전체 스코프를 만들어 제공할 수는 없다. 그러면 사용자가 타이핑하는 위치에서는 정의되지 않은 변수도 추천하게 된다. scope_at이 이를 해결한다. 프로그램의 특정 위치에서 스코프를 만든다:
rslet QueryKey::ScopeOf(uri, cursor) = key else { unreachable!() }; let node = this.syntax_node_starting_at(uri.clone(), cursor.clone())?; let ast_node = this.ast_node_of(uri.clone(), node)?;
우리의 파워 듀오는 또 등장한다. 최종 후보는 소스에 적힌 식별자 이름이어야 한다. 하지만 AST는 대부분 Var를 쓰는데, 이는 자동 완성 후보로 적합하지 않다. 이 간극을 메우기 위해 디슈거링 AST와 typed AST를 zip한다:
rslet desugar = this.desugar_of(uri.clone()); let types = this.types_of(uri.clone()); let scoped_ast = zip_ast(desugar.ast, types.ast); let Some(parents) = scoped_ast.parents_of(ast_node.id()) else { return Some(HashMap::default()); };
이로써 사람이 읽을 수 있는 변수 이름과 타입을 모두 담는 Ast<(String, TypedVar)>를 얻는다. zip된 AST에서 현재 노드의 부모를 걷는다. 부모가 없다면 AST 루트에 있으니 스코프에 정의된 게 없다. 부모에서 찾은 바인딩을 해시맵에 모은다:
rslet scope: HashMap<String, String> = parents .into_iter() .rev() .filter_map(|ast| match ast { Ast::Fun(_, (name, typed_var), _) => Some(( name.clone() , prettyprint_ty(&typed_var.1) )), _ => None, }) .collect(); Some(scope)
부모를 역순으로 걷는 것이 중요하다. 그러면 가장 가까운 바인딩이 마지막에 와서 해시맵에서 이전 바인딩을 섀도잉한다. 여기서 섀도잉이 중요한 이유는 Var가 아니라 사용자 정의 변수 이름으로 작업하기 때문이다. 다시 completion_of로 돌아와 scope로 완성 후보 배열을 만든다:
rsSome(CompletionResponse::Array( scope .into_iter() .map(|(name, pretty_ty)| CompletionItem { label: name, kind: Some(CompletionItemKind::VARIABLE), detail: Some(pretty_ty), ..Default::default() }) .collect(), ))
이로써 자동 완성도 완료했고, LSP를 구현했다!
LSP를 구현했다… 거의. 소스 코드를 보면 여기서 다루지 않은 코드가 꽤 많을 것이다. 그건 튜토리얼이 LSP 구현을 숨기려는 게 아니다. LSP를 플레이그라운드에 연결하기 위해 코드가 꽤 필요했고, LSP 코드에서 깔끔히 분리하기가 쉽지 않았다.
하지만 그만한 가치가 있다. 플레이그라운드를 꼭 보라! LSP 기능을 모두 보여줄 뿐 아니라 컴파일러 각 단계의 트리도 보여준다. 코드 실행 결과(맞다, 코드는 실행할 수도 있다; 컴파일만 하는 게 아니다)와 LSP가 보내는 요청 로그도 보여준다. 이 모든 것이 당신의 애정이라는 저렴한 가격으로 제공된다.
이 마일스톤에 도달해서 정말 기쁘다. 언어는 소박하지만, 끝에서 끝까지 컴파일러 전체를 구현했고 LSP까지 붙였다. 결코 만만한 일이 아니다. 이 여정에 함께해줘서 정말 고맙다. 시작할 때는 타입 추론에 대한 작은 글 하나만 쓰려 했는데,
입맛이 돌자 계속 달렸고, “완성”이라 부를 만한 곳까지 도달하고 싶었다. 시리즈에 대한 넘치는 응원도 물론 큰 힘이 됐다. 시리즈를 즐긴다거나 배웠다고 연락해준 모든 분들께 감사한다. 정말 큰 의미가 있다.
이 글은 그 마일스톤을 충족한다. 설명이 교육적이었길 바란다. 물론 할 일은 끝이 없다. 우리 언어에는 최상위 함수, 데이터 타입, 모듈 등등이 없다.
하지만 그런 기능들을 쫓아가기 전에, 아마 다른 주제로 글을 좀 쓰면서 쉬고 싶다. 지난 1년 포스트 기록을 보면 거의 전부 언어 만들기 글이다. 잠시 놀다가, 또 어떤 기능을 위해 모든 패스를 다시 쓰는 일(특히 rows나 items 같은 녀석들)을 하러 돌아오고 싶다. 언젠가는 base 위에 정말 화려한 것들을 쌓아 올리기 위해 다시 시리즈를 이어가고 싶다.
연락하기 · thunderseethe · Email: