FPGA開発日記

カテゴリ別記事インデックス https://msyksphinz.github.io/github_pages , English Version https://fpgadevdiary.hatenadiary.com/

EmacsのSystemVerilog環境を自作する (1. lexerとparser、highlightとindent)

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件まで持っていった。

github.com

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回だけ行う。