Tilia—a new formatter for Haskell
Tilia — Haskell을 위한 새로운 포매터
Mark Karpov이 처음부터 새로 작성한 Haskell 포매터 Tilia가 Hackage에 첫 릴리스로 공개되었습니다. ghc-lib-parser 기반 위에서 오랫동안 난제로 꼽히던 연산자 픽시티(fixity) 추론과 CPP 전처리기 지원을 원리 있는 방식으로 해결하려 시도했다는 점이 핵심입니다. 포매터가 Cabal 빌드 플랜과 공생하며 실제 의존성 정보를 읽어 정확도를 보장하는 설계가 특징입니다.
- 주제
본문
Haskell은 여전히 포매팅하기 어려운 언어입니다. ghc-lib-parser가 GHC의 실제 파서를 노출해주면서 파싱·출력 자체는 비교적 쉬워졌지만, 저자인 Mark Karpov은 수년간 세 가지 난제가 해결 불가능해 보였다고 말합니다. 첫째는 주석(comment)의 올바른 처리인데, 지난달 작업을 통해 해결됐다고 선언할 만큼 충분히 배웠다고 합니다. 둘째는 연산자 체인의 포매팅, 정확히는 연산자 픽시티를 절대적인 정밀도로 추론하는 문제이고, 셋째는 CPP 지원입니다. Tilia는 이 세 가지를 원칙 있는 방식으로 해결하기 위해 처음부터 새로 작성된 포매터이며, 이 글에서는 두 번째와 세 번째 문제를 집중적으로 설명합니다.
연산자 픽시티: 하드코딩 대신 실제 정보 수집
커스텀 픽시티를 가진 연산자가 없다면 포매터 작성자에게 얼마나 좋을까 싶지만, 연산자 체인을 포매팅하려면 일반적으로 각 연산자의 우선순위를 알아야 합니다. 저자가 아는 접근법은 두 가지입니다. 첫째는 픽시티를 하드코딩하는 것으로, 가장 정교하게 해도 Hackage 전체를 스캔해 작은 데이터베이스를 만들어 포매터에 싣는 방식입니다. 하지만 이는 사용자의 커스텀 연산자에는 소용이 없어서 수동 지정을 허용해야 하고, 재수출(re-export) 처리도 별도로 필요합니다. 둘째는 GHC 및 Cabal과 협력해 실제 정보를 얻는 것으로, 재수출의 모든 복잡성을 처리하면서 포매팅 대상 소스 코드에서 연산자 픽시티까지 수확하는 방법입니다. 저자에 따르면 두 번째 접근은 지금까지 시도된 적이 없으며, 직접 구현해보니 그 이유를 알겠다고 할 만큼 험난했다고 합니다.
의존성의 픽시티를 알아내는 경로는 두 가지입니다. 이미 설치된 패키지(부트 라이브러리나 Nix 셸에 있는 것)라면 인터페이스 파일을 읽으면 되고, 그렇지 않다면 소스 tarball을 받아 직접 파헤쳐야 합니다. 인터페이스 경로는 GHC가 이미 컴파일했기 때문에 CPP나 .hsc 파일 같은 잡다한 문제를 신경 쓸 필요가 없어 훨씬 쉽습니다. 소스를 직접 소비하면 이 모든 것을 처리해야 합니다. 다행히 픽시티는 정의된 모듈의 문법적 특성이라 컴파일은 필요 없고, 재수출 그래프를 순환까지 처리하며 따라가 모든 연산자를 발견한 뒤 결과를 캐시하면 됩니다.
하지만 여기서 끝이 아닙니다. import Data.List.NonEmpty (NonEmpty (..)) 같은 임포트는 :| 연산자를 명시하지 않고 타입의 (..)로 끌어오므로, 모듈이 어떤 이름을 export하는지만 알아서는 부족합니다. 순환을 포함한 재수출 추적, 각 export된 타입·클래스가 함께 가져오는 자식(데이터 생성자, 레코드 필드, 클래스 메서드, 연관 타입) 추적, Haskell의 두 네임스페이스 구분(타입의 :|과 식의 :|은 픽시티가 다를 수 있음), 그리고 임포트가 qualified인지, 어떤 별칭인지, 명시적 목록인지 hiding 절인지까지 모두 고려해야 합니다. 어떤 것도 무시할 수 없는데, 건너뛴 모듈이 찾는 연산자나 같은 철자의 다른 연산자를 정의할 수 있기 때문입니다. 후자의 경우 추측하지 않고 모호성을 보고해야 '절대적 정확도' 약속을 지킬 수 있습니다.
Cabal과의 공생과 CLI
이런 요구사항 때문에 Tilia는 Cabal과 공생적(혹은 기생적?) 관계를 맺게 되었습니다. 빌드 플랜을 확인하고 필요하면 새로 풀어달라고 요청하며, 소스 tarball 다운로드도 요청합니다. 이는 일회성 비용으로 빌드해본 적 없는 대형 프로젝트라도 몇 초면 끝납니다. CLI는 Cabal 컴포넌트 단위로 동작하는데, tilia inplace [COMPONENT]로 파일을 직접 포매팅하거나 tilia check [COMPONENT]로 포매팅 여부를 검사하며, COMPONENT를 생략하면 all이 기본값입니다. lib:tilia, exe:tilia, test:tests 같은 지정이 가능합니다. Nix 환경에서는 인터페이스 파일을 읽기 때문에 완벽히 동작하고, Stack 전용 지원은 없지만 Stack 프로젝트도 대체로 잘 동작하며, cabal.project의 GitHub·임의 URL 비공개 의존성도 지원됩니다.
CPP를 일급 포매팅 대상으로
CPP는 Tilia에서 일급 포매팅 대상입니다. 저자는 이를 위해 백보드로 돌아가 SuperC(Gazzillo & Grimm, PLDI 2012), TypeChef(Kästner et al., OOPSLA 2011) 논문과 Erwig·Walkingshaw의 choice calculus(TOSEM 2011)를 읽었다고 합니다. Haskell 특유의 어려움은 CPP를 지원하는 파서가 없다는 점인데, '여기가 CPP 조건문'이라고 말해주는 AST 노드가 없고 GHC 파서를 수정하는 것도 불가능합니다. 저자가 택한 방법은 다음과 같습니다. 모듈의 CPP 구성(configuration)을 열거하고, 각 구성에 존재하지 않는 행을 공백 처리해 구성별 모듈 버전을 만들며, 어떤 분기를 택했는지 결정 벡터로 기록합니다. CPP가 남지 않은 이 버전들을 일반 파서에 넣고, 각 결과를 중간 데이터 타입으로 렌더링한 뒤, 모든 결과를 순회하며 실제 차이만을 근거로 지능적으로 다시 합쳐 필요한 곳에 CPP 지시문을 재도입합니다.
글에는 leading comma로 작성된 레코드 안에 조건부 콤마가 있는 예제가 나옵니다. Tilia는 trailing comma를 쓰므로 콤마가 조건부 경계를 넘어 이동해야 했고, #if 밖의 필드에 콤마가 붙고 조건부 필드가 새 콤마를 얻는 결과가 나왔습니다. 한 줄로 작성된 함수 적용도 인자당 한 줄로 펼쳐지면서 조건부 인자가 순서를 유지하는데, 이는 하나가 아니라 두 개의 레이아웃 결정입니다. 4인자 버전과 3인자 버전을 각각 레이아웃한 뒤 병합한 결과이며, 두 결과가 일치하는 부분은 단일 사본으로 쓰고 지시문은 차이가 나는 인자 주위에만 둡니다.
choice calculus에서 가져온 것은 표현(문서가 n항 choice 노드를 담을 수 있는 트리)과 이를 지배하는 등식 법칙입니다. 모든 대안이 같은 choice는 choice가 아니며(D⟨a, a⟩ = a), 객체 구조는 choice 밖으로 인수분해되고(D⟨f a, f b⟩ = f D⟨a, b⟩), 독립 차원의 choice는 교환됩니다. 가져오지 않은 것도 있습니다. 차원과 바인더가 없어 가드는 출력에 복사될 뿐 읽히지 않는 불투명 텍스트이고, 같은 질문을 하는 두 조건문이 연결되지 않으며, 변동은 문법 트리가 아니라 Tilia가 출력하는 문서에만 존재합니다. 저자는 CPP 지원이 흥미롭지만 프로젝트에서 가장 취약한 부분이라며, 향후 릴리스에서 가장 많은 주의를 기울일 것이라고 솔직히 밝힙니다. Tilia 첫 릴리스는 Hackage에 올라와 있으며 GitHub 저장소와 GitHub Action도 제공됩니다.
Lobsters 반응
- @hongminhee — 드디어 CPP를 지원하는 Haskell 포매터가 나왔습니다! 정말 반갑습니다. Tilia를 직접 써봐야겠습니다. 좋은 프로젝트 감사합니다.
- @morj — 좋은 점: CPP 지원과 파일이 아닌 cabal 컴포넌트 단위로 동작한다는 것. 드디어요! 아쉬운 점: '설정을 허용하지 않는다'는 방침과 개인적으로 좋아하지 않는 스타일... non-leading comma 정의를 실제로 선호하는 사람을 만나본 적이 없는데 왜 이렇게 많은(두 개의) Haskell 포매터가 그 점에 고집하는지 모르겠습니다. 이해가 안 되는 것: 포매팅에 왜 연산자 우선순위를 알아야 하죠? 들여쓰기나 줄바꿈을 바꾼다고 괄호가 삽입되거나 의미가 변하지는 않을 텐데요(프로그램이 컴파일을 멈추는 경우 외에는요).
- @janus — 그럼 모든 것을 고쳤다던 Ormolu 리팩터링은 어떻게 된 건가요? 역시 Mark Karpov 작품이잖아요. 이 프로젝트가 그것의 후속 개발인가요? Ormolu와 Fourmolu의 상태는 어떤가요?
- @morj — Fourmolu는 활발히 개발 중이며 최근에 오래 기다려온 import categories를 머지했습니다. Ormolu는 안 써서 모르겠지만 커밋이 계속 올라오는 건 다들 볼 수 있습니다. 보내주신 글이 왜 삭제됐는지 궁금하네요.
- @wffl — 글에서 빠진 세부사항이지만, trivia/주석을 어떻게 처리하는지 정말 궁금합니다. 특히 기반 파서 라이브러리가 trivia 노드를 반환하지 않는 것 같아서요.
원문: Lobsters / 번역·요약: Trawling