Automattic/harper · 언어 Rust 79% · 버전 v2.6.0 · 21개 크레이트 워크스페이스·Rust 12만 줄+ · 문법 규칙 284개 · ★ 10.5k · 라이선스 Apache-2.0 · 원저자 Elijah Potter / 현재 Automattic(WordPress.com 모회사) 관리 · shallow clone 소스 직접 분석)
글을 쓰는 도구에 문법 검사기를 붙이는 건 이제 당연한 일이 됐다. 문제는 그 대부분이 당신이 입력하는 모든 문장을 클라우드로 전송한다는 점이다. 이메일 초안, 일기, 회사 기밀 문서까지 — 검사받는 순간 남의 서버를 거친다. harper의 원저자 Elijah Potter는 이걸 "프라이버시 악몽(privacy nightmare)"이라 불렀고, 그 반대편에 서는 도구를 만들었다.
Grammarly는 "원고를 본사로 보내면 첨삭해 돌려주는 우편 서비스"다 — 빠르지만 남이 내 글을 다 본다. harper는 "사전과 문법책을 통째로 압축해 주머니에 넣은 것"이다. 인터넷이 끊겨도, 비행기 안에서도, 회사 방화벽 안에서도 내 기기만으로 즉시 첨삭된다. 아무것도 밖으로 나가지 않는다.
harper가 하는 일은 단순하다. 텍스트를 받아 → 토큰(단어·기호 조각)으로 쪼개고 → 품사를 붙이고 → 284개의 문법·철자·문체 규칙을 적용해 → 오류 위치와 수정 제안을 내놓는다. 이 코어 엔진(harper-core)을 세 가지 얼굴로 재사용한다: 에디터에 붙는 LSP 서버(harper-ls), 브라우저에서 도는 WASM(harper-wasm), 그리고 명령줄 도구(harper-cli)다.
문법 검사기 시장엔 이미 거인들이 있다. Grammarly(상용), LanguageTool(오픈소스), hunspell(철자만). harper가 이들 사이를 비집고 트렌딩에 오른 이유는 딱 하나로 요약된다 — 속도와 프라이버시를 동시에 잡았고, 그 격차가 "조금"이 아니라 "자릿수(order of magnitude)" 수준이라는 것이다.
레포의 COMPARISON.md가 제시하는 정면 비교표다(원문 인용):
| 제품 | 제안 생성 시간 | 라이선스 | LSP 지원 | 룰셋 |
|---|---|---|---|---|
| harper | 10ms | Apache-2.0 | ✅ 네이티브 | 자체 규칙 |
| LanguageTool | 650ms | LGPL-2.1 | 🟨 ltex-ls 경유 | 커스텀 + N-그램 + LLM |
| hunspell | — | LGPL/GPL/MPL | ❌ | hunspell/MySpell |
| Grammarly | 4000ms | 독점(비공개) | 🟨 서드파티 경유 | 독점 |
숫자를 곱씹어 보자. harper의 10ms는 LanguageTool(650ms)보다 약 65배, Grammarly(4000ms)보다 약 400배 빠르다. 문법 검사에서 속도는 단순한 편의가 아니다. 타이핑하는 동안 글자마다 실시간으로 검사하려면, 왕복 수백 밀리초는 곧 "느껴지는 렉"이 된다. harper는 그 지연을 사람이 인지하기 어려운 수준으로 떨어뜨렸다.
LanguageTool은 정확도를 위해 n-그램(단어 n개 연쇄) 통계 데이터셋을 쓴다. "이 단어 다음엔 이게 자주 온다"를 방대한 말뭉치로 학습한 표인데, 그 크기가 ~16GB에 달하고 수 기가바이트의 RAM을 요구한다. 강력하지만 무겁고, 로딩도 느리고, 브라우저에 넣는 건 꿈도 못 꾼다.
harper는 n-그램 대신 FST(유한상태 변환기)로 압축한 5.4만 단어 사전과 손으로 짠 284개 규칙만 쓴다. README의 주장: "LanguageTool 메모리 사용량의 1/50 미만, 밀리초 단위 린트, 완전 비공개." 사전을 실행 파일에 include_str!로 내장했기 때문에 다운로드도 로딩도 없다. 이 "가벼움"이 곧 WASM 이식과 오프라인 동작을 가능케 한 원천이다.
또 하나 놓치기 쉬운 강점은 침투 범위다. harper는 순수한 산문만 검사하는 게 아니라, harper-comments + tree-sitter로 소스 코드의 주석과 문자열만 뽑아 검사하고, Markdown·LaTeX·Typst·AsciiDoc·Org-mode·깃 커밋 메시지까지 포맷별 파서를 갖췄다. 개발자가 코드를 쓰든 문서를 쓰든 커밋을 쓰든, 같은 엔진 하나가 따라붙는다. 이 "어디에나 붙는 단일 코어" 전략이 harper를 단순 맞춤법 검사기 이상으로 만든다.
harper는 Rust 에디션 2024로 쓰인 거대한 Cargo 워크스페이스다. 워크스페이스란 여러 개의 크레이트(Rust의 패키지 단위)를 한 저장소에서 함께 빌드·관리하는 구조인데, harper는 무려 21개로 잘게 쪼개 놨다. "왜 이렇게 잘게?"의 답이 곧 이 프로젝트의 설계 철학이다 — 코어는 순수하게, 주변부는 갈아끼울 수 있게.
| 크레이트 | 역할 |
|---|---|
| harper-core | 엔진 심장. 토큰화·파싱·FST 사전·284개 규칙·Expr DSL이 전부 여기 |
| harper-ls | LSP 서버. 에디터(VS Code·Neovim 등)와 표준 프로토콜로 통신 (tower-lsp 기반) |
| harper-cli | 개발·디버그용 명령줄 도구 (lint·parse·metadata·train 등 서브커맨드) |
| harper-wasm | WebAssembly 바인딩. 같은 코어를 브라우저에서 구동 (writewithharper.com) |
| harper-brill | Brill 품사 태거 + Burn 신경망 청커(명사구 인식). 사전학습 모델 내장 |
| harper-comments | 소스 코드에서 주석·문자열만 뽑아 검사 (tree-sitter 활용) |
| harper-html / typst / tex / asciidoc / python … | 포맷별 파서. 마크업·문서·커밋 메시지 등 "산문이 아닌 것" 속의 산문을 골라냄 |
| harper.js (npm) | WASM을 감싼 JS 패키지. VS Code·Obsidian·Chrome·WordPress 플러그인이 공유 |
| harper-desktop | Tauri 2 기반 데스크톱 앱 (Svelte 프론트엔드) |
엔진 심장부의 의존성을 보면 harper가 "무엇으로 빠른가"가 드러난다. 무거운 ML 프레임워크가 아니라, 작고 날카로운 자료구조 라이브러리들로 채워져 있다.
| 크레이트 / 버전 | 역할 |
|---|---|
| fst 0.4.7 | 유한상태 변환기. 5.4만 단어 사전을 압축 저장하고 초고속 조회 — harper 속도의 핵심 |
| levenshtein_automata 0.2.1 | 편집거리 기반 퍼지 매칭. 오타가 나도 비슷한 단어를 찾아 교정 후보로 |
| pulldown-cmark 0.13 | 마크다운 파서 (CommonMark 준수) |
| smallvec · hashbrown · foldhash | 할당을 줄이는 고성능 컬렉션·해시 — "무할당" 설계의 도구들 |
| lru · cached | 반복 조회 캐싱 |
| criterion · quickcheck | (개발용) 벤치마크 · 속성 기반 테스트(무작위 입력으로 규칙 검증) |
릴리스 빌드 프로파일도 성능에 진심이다. Cargo.toml은 opt-level=3(최대 최적화), lto="fat"(링크 시점 전역 최적화), panic="abort"(패닉 처리 코드 제거로 바이너리 축소)로 설정돼 있다. "빠른 언어를 골랐으니 끝"이 아니라, 컴파일러가 낼 수 있는 최대치까지 짜냈다는 뜻이다.
GitHub 기준 언어 비율은 Rust 79.1% · TypeScript 11.6% · Svelte 6.0% 순이다. 로컬 집계로는 Rust가 590개 파일 12만 줄+, TS가 154개 파일 1.8만 줄. 즉 두뇌는 전부 Rust이고, JS/TS/Svelte는 그 두뇌를 브라우저·에디터·웹사이트에 연결하는 얇은 배선이다. 프론트엔드 툴체인은 Node 22+·pnpm 10·포매터는 Biome를 쓴다.
harper를 이해하는 가장 빠른 길은 "텍스트 한 줄이 어떤 컨베이어 벨트를 지나 '여기 오타요'가 되는지"를 따라가는 것이다. 부품 이름부터 외우면 길을 잃는다. 흐름부터 보자.
이 벨트에서 가장 영리한 결정이 ①번에 숨어 있다. harper는 문자열을 잘라 복사하지 않는다. 원문을 Lrc<[char]>(참조 카운트 공유 배열)로 딱 한 번 만들어 두고, 모든 토큰은 "원문의 몇 번째부터 몇 번째까지"라는 범위(span)만 들고 다닌다. 이게 무할당(zero-copy) 설계의 핵심이다 — 수천 개 토큰을 만들어도 문자열 복사가 0번이니, 그만큼 빠르고 메모리를 덜 쓴다.
harper의 문법 규칙 284개는 어떻게 관리될까? 답은 Rust의 트레잇(trait)이다. 모든 규칙은 Linter라는 공통 인터페이스를 구현하는 "무상태 부품"이다.
pub trait Linter: LSend {
fn lint(&mut self, document: &Document) -> Vec<Lint>;
fn description(&self) -> &str;
}
그런데 규칙 284개를 매번 이 저수준 트레잇으로 손수 짜면 지친다. 그래서 harper는 대부분의 규칙이 쓰는 고수준 트레잇 ExprLinter를 얹었다. "찾을 패턴(expr)"과 "매치되면 어떻게 고칠지(match_to_lint)"만 선언하면, Linter 구현은 자동으로 딸려 온다(블랭킷 impl).
pub trait ExprLinter: LSend {
type Unit: DocumentIterator; // Chunk(절) | Sentence(문장) 단위
fn expr(&self) -> &dyn Expr; // 찾을 패턴
fn match_to_lint(&self, matched: &[Token], src: &[char]) -> Option<Lint>;
fn description(&self) -> &str;
}
이 Expr가 바로 harper가 과거 의존하던 nlprule을 걷어내고 자체 개발한 선언적 패턴 언어다. 소스 주석은 그 동기를 이렇게 적는다 — "규칙을 쓰기 쉽게, 그리고 읽기 쉽게. 남의 Rust 코드 로직을 손으로 파싱하던 시절은 끝." SequenceExpr 빌더에 40개+ 콤비네이터를 체이닝해 "비교급 형용사 → 공백 → that → 단어" 같은 패턴을 문장처럼 선언한다.
Expr DSL은 정규식(regex)의 문법판이라고 보면 된다. 정규식이 "글자 패턴"을 [a-z]+처럼 선언하듯, Expr는 "품사·단어·공백의 시퀀스"를 .then_kind(비교급).t_ws().t_aco("that")처럼 선언한다. 규칙 하나 추가가 "복잡한 로직 짜기"에서 "패턴 한 줄 쓰기"로 바뀐다.
이 284개는 LintGroup이라는 중앙 레지스트리가 매크로로 등록·실행한다. 내역을 세어 보면 Expr 기반 224개 + 커스텀 구조체 35개 + 사전/방언 필요 25개다. 규칙의 종류(LintKind)도 21가지로 세분화돼 있는데, 흔한 오타·철자뿐 아니라 Eggcorn(민간어원 오류, 예: "for all intensive purposes"), Malapropism(비슷한 소리 오용), 중복 표현, 지역 방언까지 잡는다.
"오프라인인데 어떻게 오타 교정까지?"의 답이 여기 있다. harper의 사전은 dictionary.dict(hunspell 스타일, 54,719줄)를 FST(Finite State Transducer)로 컴파일해 실행 파일 안에 넣는다(include_str!). FST는 수만 단어를 글자 공유 그래프로 압축해, 조회를 "단어 길이에 비례하는" 속도로 처리하는 자료구조다.
오타 교정은 여기에 레벤슈타인 오토마톤(Levenshtein automaton)을 교차시킨다. "편집거리 3 이내의 모든 단어"를 받아들이는 오토마톤을 즉석에서 만들고, 그걸 FST와 교차(intersection)해 비슷한 단어 후보를 한 번에 스트리밍으로 뽑는다. 후보 랭킹도 정교하다 — 첫 글자가 같으면 가점, 흔한 단어면 가점, 방언 변형(color/colour, -ize/-ise)을 인지해 감점하는 휴리스틱이 붙어 있다. 접사(affix) 확장도 있어서, annotations.json의 접두/접미사 규칙(un-, -ment, -ly …)이 어간을 곱해 실효 어휘를 5.4만보다 훨씬 크게 파생시킨다.
harper-ls는 언어 서버 프로토콜(LSP)을 구현한다. LSP는 마이크로소프트가 표준화한 "에디터↔언어도구" 공용 규약으로, 이걸 구현하면 VS Code·Neovim·Helix·Emacs·Zed에 한 번에 붙는다. 통신 방식은 기본 TCP(127.0.0.1:4000) 또는 --stdio 표준입출력 모드다.
즉 당신이 에디터에서 타이핑하면 did_change가 날아가고, harper-ls가 즉시 재검사해 밑줄(publish_diagnostics)을 그린다. 밑줄에 커서를 올려 퀵픽스를 부르면 code_action으로 수정 제안이 온다. 이 왕복이 전부 로컬 프로세스 간 통신이라 네트워크가 개입하지 않는다 — 2번 섹션의 "10ms"가 실제 편집 경험으로 체감되는 지점이다.
워크스페이스 최상위를 열면 harper-* 크레이트들이 나란히 있고, 그 무게중심은 압도적으로 harper-core에 쏠려 있다. 아래는 핵심만 추린 트리다.
| 디렉토리 | 무슨 일을 하나 |
|---|---|
| harper-core/src/linting/ | 규칙의 몸통. 규칙 하나 = .rs 파일 하나(테스트 포함). 새 규칙은 여기 추가 |
| harper-core/src/expr/ | 규칙을 "패턴"으로 선언하는 DSL. 콤비네이터(All·Optional·Repeating …)의 집합 |
| harper-core/src/spell/ | FST 사전·가변 사전·병합 사전. 철자 교정 로직의 본진 |
| harper-core/src/parsers/ | 입력 포맷별 파서. "Markdown 속 산문만" 같은 선별을 담당 |
| packages/ | Rust 코어를 각 플랫폼(에디터·브라우저·WP)에 연결하는 JS 껍데기 |
이 구조의 교훈: "확장 지점을 파일 규칙으로 못박아 두면 기여가 쉬워진다." 새 문법 규칙을 넣고 싶으면 linting/에 .rs 하나(로직 + #[test])를 만들어 LintGroup에 매크로로 등록하면 끝이다. 코어의 나머지는 손대지 않는다. iFixAi가 "검사 하나 = 폴더 하나"였다면, harper는 "규칙 하나 = 파일 하나 + 등록 한 줄"이다.
모든 규칙은 Linter 트레잇을 구현하는 무상태 부품이다. 그래서 엔진은 규칙이 몇 개든 Vec<Box<dyn Linter>>로 담아 똑같이 순회하면 된다. 대표 규칙 ThatThan("비교급 that → than" 오타)을 보면, 로직이 아니라 패턴 선언임을 알 수 있다.
impl Default for ThatThan {
fn default() -> Self {
let expr = SequenceExpr::default()
.then_kind_except(TokenKind::is_comparative_adjective, &["better","later"])
.t_ws().t_aco("that").t_ws() // 공백-that-공백
.then_word_except(&["way"]);
Self { expr }
}
}
배울 점: "블랭킷 impl"로 고수준 트레잇(ExprLinter)만 구현하면 저수준 트레잇(Linter)이 공짜로 딸려오게 설계하면, 기여자는 쉬운 쪽만 보면 된다.
파싱에서 가장 흔한 낭비가 부분 문자열 복사다. harper의 Token은 원문을 복사하지 않고 span(범위 정수 2개)만 보유한다. 원문은 Lrc<[char]>로 공유된다.
pub struct Token {
pub span: Span<char>, // 원문의 [start, end) 범위
pub kind: TokenKind,
}
pub enum TokenKind {
Word(Option<DictWordMetadata>), // None = 사전에 없는 단어
Punctuation(Punctuation), Number(Number),
Space(usize), Url, EmailAddress, Unlintable, …
}
배울 점: 수천 개 토큰을 만들어도 힙 할당이 거의 없다. "복사 대신 참조 범위"는 Rust 파서·렉서의 기본 성능 공식이다.
5.4만 단어를 fst::Map으로 압축해 바이너리에 내장하고, 오타가 나면 레벤슈타인 오토마톤을 FST와 교차해 편집거리 이내 후보를 스트리밍으로 뽑는다. "사전을 자료구조로 이긴다"의 교과서적 예시.
배울 점: 맞춤법 검사·자동완성·퍼지 검색을 만들 때, 해시맵을 순회하며 일일이 편집거리를 재는 대신 FST 교차를 쓰면 자릿수 빠르다. Lucene·Elasticsearch가 같은 원리를 쓴다.
harper는 남의 라이브러리(nlprule)를 걷어내고 자체 Expr DSL을 만들었다. 규칙 저자는 절차적 코드가 아니라 SequenceExpr 체이닝으로 "찾을 패턴"을 읽히는 선언으로 표현한다. 규칙이 많아질수록 이 "쓰기 쉬움 + 읽기 쉬움"이 복리로 이득이 된다.
배울 점: 같은 형태의 규칙이 수백 개 생길 프로젝트라면, 규칙마다 로직을 짜지 말고 작은 내부 DSL을 먼저 설계하라. 유지보수 비용이 급감한다.
harper-core 하나를 harper-cli(네이티브), harper-ls(LSP), harper-wasm(브라우저)이 공유한다. 로직을 한 곳에만 두고 얇은 바인딩만 갈아끼우니, 웹 데모(writewithharper.com)와 VS Code 확장이 정확히 같은 규칙으로 동작한다.
배울 점: 핵심 로직을 플랫폼 독립적인 순수 크레이트로 격리하면, WASM 컴파일 한 번으로 브라우저까지 무료로 확장된다. "코어를 순수하게" 지킨 21-크레이트 분할이 이 순간 보상받는다.
| 항목 | 요구사항 / 참고 |
|---|---|
| 실행 환경 | 네이티브 바이너리(Windows·macOS·Linux) 또는 WASM(브라우저). 사전이 내장돼 추가 다운로드 0 |
| 하드웨어 | GPU 불필요. LLM을 돌리지 않고 규칙·FST·경량 품사 모델만 사용 |
| 메모리 | README 주장: LanguageTool의 1/50 미만. n-그램 16GB 같은 부담 없음 |
| 네트워크 | 불필요. 오프라인 완전 동작 — 프라이버시 우선 설계의 귀결 |
| 지원 언어(검사 대상) | 현재 영어 전용(미국·영국·캐나다·호주 방언 구분). 구조는 타 언어로 확장 가능하나 미지원 |
| 빌드 툴체인 | Rust stable + wasm32-unknown-unknown 타깃 · Node 22+ · pnpm 10 · just. Nix flake 제공 |
| 에디터 통합 | VS Code(전용 확장) · Neovim · Helix · Emacs · Zed · Obsidian · Chrome/Firefox · WordPress |
| 라이선스 | Apache-2.0 (상업적 사용·수정·재배포 자유) |
설치 없이 writewithharper.com에 접속해 일부러 오타·중복 표현·비교급 실수를 넣어 본다. WASM으로 브라우저 안에서만 도는 harper가 어떤 오류를 어떻게 잡는지 눈으로 익히는 게 목적. 개발자도구 네트워크 탭을 열어 "입력이 서버로 안 나간다"를 직접 확인해 보면 프라이버시 우선 설계가 체감된다.
VS Code에서 "Harper" 확장(publisher: elijah-potter)을 설치하거나, Neovim/Zed의 LSP 설정에 harper-ls를 등록한다. 마크다운 문서를 열어 실시간 밑줄과 퀵픽스를 써 보고, "사전에 단어 추가" 액션으로 가변 사전이 어떻게 동작하는지 관찰한다.
레포를 클론하고 harper-cli를 빌드해 파이프라인의 중간 산물을 직접 본다. cargo run --bin harper-cli -- parse <파일>로 토큰화 결과를, spans로 span 시각화를, metadata <단어>로 사전이 그 단어에 붙인 품사·굴절 정보를 출력해 본다. 4번 섹션의 ASCII 흐름이 실제 데이터로 보인다.
harper-core/src/linting/에서 that_than.rs를 복사해 나만의 규칙(예: 자주 틀리는 특정 표현 교정)을 만든다. ExprLinter의 expr()에 SequenceExpr 패턴을 선언하고 match_to_lint()에서 Suggestion::replace_with_match_case_str(...)로 제안을 만든 뒤, 같은 파일에 #[test]로 assert_suggestion_result를 붙여 검증한다. 마지막으로 LintGroup에 등록. "패턴 선언 → 테스트"의 개발 사이클을 체험하는 핵심 실습.
just build-wasm → just build-harperjs로 harper.js를 빌드하고, 간단한 HTML 페이지에서 import해 텍스트 입력을 실시간 검사하는 미니 에디터를 만든다. "순수 코어를 WASM 한 번으로 브라우저까지" 확장하는 크로스 컴파일 파이프라인을 손으로 굴려 보는 과제. 번들 크기와 초기 로딩 시간을 재 보면 "가벼움"의 설계 가치가 숫자로 다가온다.
| 주차 | 주제 | 학습 자료 / 실습 |
|---|---|---|
| 1주차 | Rust 소유권·트레잇·제네릭 | The Rust Book 4·10·17장 · harper의 Linter/ExprLinter 트레잇 읽기 |
| 2주차 | 렉싱/파싱 · 무할당 설계 | token.rs·document.rs 정독 · span 기반 토큰을 직접 구현 |
| 3주차 | FST · 레벤슈타인 오토마톤 | fst 크레이트 문서 · spell/fst_dictionary.rs · 퍼지 검색 미니 프로젝트 |
| 4주차 | 내부 DSL(패턴 언어) 설계 | expr/ 디렉토리 · 콤비네이터 패턴 · 작은 매처 DSL 만들어 보기 |
| 5주차 | LSP 서버 구현 | harper-ls/src/backend.rs · tower-lsp 튜토리얼 · 미니 언어서버 작성 |
| 6주차 | WASM 크로스 컴파일 · 배포 | harper-wasm · wasm-pack · just 빌드 시스템 · npm 배포 흐름 |
| 용어 | 의미 |
|---|---|
| FST | 유한상태 변환기. 단어 사전을 글자 공유 그래프로 압축해 초고속 조회 — harper 속도의 핵심 |
| 레벤슈타인 오토마톤 | "편집거리 N 이내 단어"를 받아들이는 오토마톤. FST와 교차해 오타 교정 후보를 뽑음 |
| LSP | 언어 서버 프로토콜. 에디터↔언어도구 공용 규약 — 한 번 구현하면 여러 에디터에 붙음 |
| Linter / ExprLinter | 모든 규칙의 트레잇(인터페이스). ExprLinter는 "패턴 + 변환"만 선언하는 고수준 버전 |
| 블랭킷 impl | 어떤 조건을 만족하는 모든 타입에 트레잇을 한꺼번에 구현. ExprLinter→Linter 자동 연결에 사용 |
| Expr DSL | harper 자체 패턴 선언 언어. nlprule을 대체 — "정규식의 문법판" |
| 무할당(zero-copy) | 문자열을 복사하지 않고 원문의 범위(span)만 참조하는 설계. 파서 성능의 기본기 |
| span | 원문에서 "몇 번째~몇 번째" 범위를 가리키는 정수 쌍. 토큰이 문자열 대신 이것만 보유 |
| LintGroup | 284개 규칙을 매크로로 등록·실행하는 중앙 레지스트리 |
| hunspell affix | 어간 + 접사(un-, -ment …) 규칙으로 어휘를 대량 파생하는 사전 포맷. harper가 채용 |
| Brill 태거 | 규칙 기반 품사 태거. 거대 모델 없이 변환 규칙으로 품사를 판정 |
| Burn | Rust 딥러닝 프레임워크. harper의 경량 명사구 청커(신경망) 구동에 사용 |
| tree-sitter | 증분 파서 생성기. harper가 소스 코드에서 주석·문자열만 추출하는 데 활용 |
| 크레이트 / 워크스페이스 | Rust의 패키지 단위 / 그런 패키지 여럿을 묶는 상위 컨테이너(harper는 21개) |
| Eggcorn / Malapropism | 민간어원 오류 / 비슷한 소리 단어 오용. harper가 잡는 고급 오류 종류 |
COMPARISON.md(경쟁 비교표) · README.md(설계 철학) · docs/harper-core/src/linting/mod.rs(Linter 트레잇) · expr_linter.rs · that_than.rs(규칙 예시) · spell/fst_dictionary.rs(FST 사전) · harper-ls/src/backend.rs(LSP)harper-ls --stdio를 에디터 LSP에 연결