RTLを書くときはverilog-modeを使ってきたが、indentが合わない、自分でtypedefした型に色が付かない、書き間違えてもVerilatorを回すまで気付かない、という不満が溜まっていた。 今回はEmacs Lispだけでlexerとparserを書き、その上にhighlightとindentを載せるところまでの記録。 lint、formatter、rename、階層browserは次回に回す。
結果としてElisp 6312行、ERT test 124本になり、PULPのaxiとcommon_cells (225 file / 314 design unit) に対してparse error 0件まで持っていった。
Emacs 27.1以降で動く。外部toolもpackage依存も無い。
(add-to-list 'load-path "/path/to/emacs-sv-kit/lisp") (require 'sv-mode)
これで.sv / .svh / .v / .vhがsv-modeで開き、highlight、indent、lint (Flymake) が有効になる。
実装はAIアシストで進めた
先に断っておくと、このpackageの実装はClaude Codeを使って書いた。 方針 — 外部toolに頼らず自作parserで行くこと、何をどの順で作るか、どこで精度を落としてでも誤検知を避けるか — は自分で決め、実装とdebugをAIに回した。
やってみて効いたのは、この題材が機械的に検証できる形をしていることだった。 「parse error 0件」「token列が変化しない」「2回整形しても変わらない」は全部、実codeを食わせれば真偽が確定する。 AIが書いたcodeを人間が目視で信用する必要がなく、225 fileに対して走らせて不変条件が破れるかどうかを見ればよい。 後述するbugは、どれもそのloopで出てきたものである。
逆に、AIに任せきりにできなかったのは仕様の側だった。
「x[i]を1 bitと決めつけない」「generateの条件を評価してから幅を見る」といった判断は、誤検知を1件ずつ見て決めるしかない。
lintの指摘が1939件から979件まで減る過程は、大半がこの作業である。
外部toolを繋ぐのではなくparserを書いた理由
VerilatorやVeribleをLSPやFlycheck経由で使う方法はある。 ただしそれは「toolを入れていない環境では何も動かない」ということでもある。
加えて、editorが欲しいのはerror listだけではない。補完もjumpもrenameも、結局は同じ構文情報を要求する。 外部toolに投げるとその情報がprocess境界の向こうに閉じる。
parserを自分で持つと、lint、formatter、補完、rename、階層treeが全部「同じ構文木に対する別の問い合わせ」になる。 正規表現baseだと機能ごとに別の正規表現を書くことになり、片方を直しても片方が直らない。 この一体感のためだけでもparserを書く価値はあった。
構成
lisp/sv-lexer.el tokenizer (空白とcommentも保持する) lisp/sv-parser.el 再帰下降parser lisp/sv-lint.el 構文木に対する静的check (30 rule) lisp/sv-format.el indent再計算・空白正規化・桁揃え lisp/sv-width.el 定数畳み込みとbit幅推論 lisp/sv-index.el project全体のsymbol索引 lisp/sv-ide.el 補完 / xref / ElDoc lisp/sv-refactor.el rename lisp/sv-hierarchy.el 設計階層browser lisp/sv-mode.el major mode lisp/sv-kit.el Flymake / imenu / keybind / CLI
今回扱うのは上2つとsv-mode.el / sv-format.elのindent部分。残りは次回。
lexer: 空白とcommentを捨てない
普通のtokenizerと違うのは、空白とcommentもtokenとして持つこと。
各tokenにstart / end / line / colを付ける。
token列を連結すると元のsourceが1 byte単位で復元できる。 この性質は次回のformatterの正しさをtestで保証するのに使う。
lexerで1箇所だけ後から直したところがある。
'{を1つの演算子tokenとして扱っていたのをやめ、'と{に分割した。
1 tokenにすると開きbraceが括弧の対応計算から見えなくなり、代入patternを含むcodeで括弧が合わなくなる。
これはPULPのcodeを食わせるまで表に出なかった。
parser: 例外を投げない
方針として1つだけ決めたことがある。絶対に例外を投げない。
実際のRTLはmacroだらけだし、編集中のfileは大抵どこかが壊れている。
「構文errorなので何も出せません」ではeditorの機能として使いものにならない。
解釈できないところに来たら次の;かend系keywordまで読み飛ばし、そこから再開する。
壊れるのはその部分だけで、fileの残りに対するhighlightもindentもlintも動き続ける。
無限loopになる条件
この読み飛ばしが無限loopの温床だった。 parserが何もtokenを消費せずにnilを返すと、その場で止まる。
最終的に、すべての呼び出しをこのmacroで包んで、必ず位置が進むことを保証した。
(defmacro sv-parse--gather (into parser)
"Call PARSER, pushing its node onto INTO, and guarantee progress."
`(let* ((before sv-parse--pos) (node ,parser))
(cond (node (push node ,into))
((= sv-parse--pos before) (sv-parse--adv)))))
最初は「nilが返ってきたら1 token進める」と書いていた。 これだと「nodeは返したが1 tokenも消費していない」場合を拾えない。 nodeの有無ではなく位置で判定するのが正しい。
highlight
font-lockはparserではなく正規表現で行う。打鍵のたびに全部parseするのは重い。 24 file 27 KBのfontifyで0.13秒。
ただし、その場で宣言された型名だけはparserから取る。 自分で書いたtypedefが組み込み型と同じように色が付く。これが一番効く。
色分けの対象は以下。
- keyword / data type / 数値literal (
8'hff,'0,10ns) - compiler directiveとmacro (
`elseをkeywordのelseと誤認しない) - 宣言された信号名・parameter名・loop変数 (
for (int i = 0; ...)のiも) - instance (module名と
u_fooを区別) .i_clk (clk)のport名、begin : nameのlabel
typedefを書き足した直後に色を反映させたいときはC-c C-t。保存時には自動で走る。
font-lockの中でsyntax-ppssを呼ぶと固まる
「今commentか文字列の中か」を知りたくてmatcherの中からsyntax-ppssを呼んだら、Emacsが固まった。
syntax-ppssはpointを動かし、match dataも壊す。
font-lockから見ると「matchしたのに位置が進んでいない」状態になり、無限loopする。
;; save-excursion と save-match-data で包むのが必須 (save-excursion (save-match-data ...))
font-lockのmatcherは「必ず前進する」ことが暗黙の契約になっている。 同じ事故を繰り返さないよう、全matcherがその契約を守ることを確かめるtestを足した。
indent: frameの深さではなく、開いた行の桁
indentはparserと同じtoken列から計算する。
正規表現では難しいcase、beginの無いif / elseの連鎖、caseの項目、複数行のport list、generateの中、が崩れない。
最初はframeの深さ×offsetで計算していた。 これだと既存のcodeに当てると全部ずれる。
endは「frameが何段あるか」ではなく、対応するbeginが書かれている行の桁に戻ってほしい。
generate for (genvar i = 1; i <= red_loop; i++) begin : bit_ff_loop assign in_shift_array[i] = in_shift_array[i-1] | (in_shift_array[i-1] << (1 << (i-1))); end endgenerate
このendが戻るべき桁は0であって、frame段数から計算した値ではない。
frame stackに「その構文を開いた行の桁」を積むようにして解決した。既存のstyleを壊さない。
endやelseを打った瞬間に再indentする (sv-mode-electric-keywordsで無効化可)。
C-M-\でregionをindentする場合、bufferの字句解析は1回だけ行う。