TO Box "Arg
FORWARD :Arg
LEFT :Arg
END
MAKE "BOXLINE "2
WHILE LT :BOXLINE "60 [
SETPENCOLOR + COLOR "2
Box :BOXLINE
IF LT "8 COLOR [
SETPENCOLOR "2
]
ADDASSIGN "BOXLINE "2
]
以 " 開頭的是字面值,以 : 開頭的是變數,COLOR 這類則是查詢海龜目前的狀態。運算子採用前綴表示法,+ COLOR "2 就是 COLOR + 2。IF 和 WHILE 後面接著條件與程式區塊,TO ... END 則用來定義程序。運算式和程式區塊都能任意巢狀組合,這正是正規表示式最不擅長處理的東西。
nom 怎麼運作
nom 是個 parser combinator 程式庫。它的想法是先寫很多小小的 parser,再像積木一樣把它們組成一個大的。
在 nom 裡,parser 就是一個函式。它接收一段輸入,解析成功後,會回傳尚未解析的輸入,以及解析出的結果。
這學期在上 UNSW 的 COMP6991,課程有個作業叫 rslogo,要用 Rust 實作 Logo 語言的直譯器。Logo 就是那個讓小海龜往前走、轉彎、抬筆落筆,最後畫出一張圖的教學語言。rslogo 要做的是讀入一個 .lg 檔案,執行裡面的命令,再把海龜畫出的圖存成 SVG。
既然要執行程式碼,首先就得把它解析出來。一種做法是全靠正規表示式,但 Logo 有巢狀的 [ ] 和運算式,用正規表示式處理這些東西太燒腦,也很容易出錯。另一種做法是用最基本的字串操作,按空格切開,再逐一判斷。這樣能做,但寫出來會是一大堆 if 和索引,非常不優雅,也一點都不 Rust。
好在這個作業允許使用第三方程式庫,所以我找了一下有沒有更符合 Rust 風格的做法,於是找到了 nom 和 pest。pest 要另外寫一個 .pest 檔案來描述語法,還得多學一套它自己的語法規則;nom 寫的則是一般的 Rust 函式,那我理所當然就選了 nom。
rslogo 結構
以下是 rslogo 要支援的程式碼:
以 " 開頭的是字面值,以 : 開頭的是變數,COLOR 這類則是查詢海龜目前的狀態。運算子採用前綴表示法,+ COLOR "2 就是 COLOR + 2。IF 和 WHILE 後面接著條件與程式區塊,TO ... END 則用來定義程序。運算式和程式區塊都能任意巢狀組合,這正是正規表示式最不擅長處理的東西。
nom 怎麼運作
nom 是個 parser combinator 程式庫。它的想法是先寫很多小小的 parser,再像積木一樣把它們組成一個大的。
在 nom 裡,parser 就是一個函式。它接收一段輸入,解析成功後,會回傳尚未解析的輸入,以及解析出的結果。
tag("PENUP") 本身回傳的就是一個 parser,所以寫法上會有兩組括號接在一起。PENUP 被吃掉後,剩下的 " FORWARD" 原樣回傳,下一個 parser 就能從這裡繼續解析。回傳的字串都是原始輸入的切片,這個過程不需要複製任何東西。
nom 好用的地方,在於它提供了一大堆現成的組合子。alt 依序嘗試幾個 parser,採用第一個成功的結果;preceded、delimited、tuple 依序比對幾個 parser,再取出需要的結果;many0 重複比對;map 則把結果轉成自己的型別。例如 "60 是一個引號後面接著數字,用 nom 寫就是這樣。
這讀起來幾乎就是語法規則本身。每個 parser 都很小,可以各自測試;組合時,型別系統也會幫你檢查每一步是否接得上。
語法樹
開始寫 parser 之前,我先用列舉定義了解析結果的樣子。
parser 要做的,就是把一段字串變成 Vec<Expr>。
rslogo 的 parser
空白
Logo 裡到處都是空格,如果每個 parser 都各自處理一次,實在太囉嗦。所以我寫了一個 ws,接收一個 parser,再回傳一個會吃掉前後空白的新 parser。
這其實就是自己寫的組合子,用起來和 nom 內建的沒有差別。
值
各種值都是前綴加上內容,寫法大同小異,最後用 alt 組合起來。
不過這裡的順序有講究。alt 會採用第一個成功的結果,而 "TRUE 既能是布林值,也能是叫做 TRUE 的名稱,所以 parse_boolean 必須放在 parse_string 前面。
前綴運算式
這段是我覺得 nom 最漂亮的地方。一個計算就是一個運算子,後面接著兩個有值的東西,而它們本身又可以是另一個計算。
parse_calculation 和 parse_unvoid 互相遞迴,所以像 AND GT XCOR "0 GT YCOR "0 這種巢狀好幾層的運算式,也能一次解析出來。前綴表示法也沒有運算子優先順序的問題,不需要括號,整個運算式的 parser 就只有這幾行。
命令和關鍵字
命令呼叫就是一個名稱,後面接著若干參數。
命令名稱和關鍵字長得一樣,都是一串字母。如果不加限制,END 也會被當成命令吃掉,TO ... END 就永遠找不到結尾。所以我用 verify 在比對到名稱後再檢查一次,是保留字就判定失敗。這樣程序定義裡的 many0(parse_expr) 遇到 END 自然會停下來,再由外層的 delimited 去比對它。
IF 和 WHILE 也是類似的寫法,條件後面用 delimited(tag("["), many0(parse_expr), tag("]")) 解析程式區塊。區塊裡又是 parse_expr,所以也就順便支援了巢狀結構。
入口
最後把所有敘述組合起來。
ErrorTree 和 final_parser 來自 nom-supreme。ErrorTree 會記下沿途嘗試過的分支,出錯時比較容易看出問題在哪。final_parser 則要求輸入必須全部吃完,否則一般的 nom parser 即使只解析了一半,也會回傳成功,剩下的內容就可能被悄悄忽略。
整個 parser 加起來也就兩百行左右,每個函式基本上都對應一條語法規則。
執行器
parser 寫完後,執行就順利多了。執行器是個結構體,保存海龜的狀態和畫圖用的畫布;畫布使用的是課程提供的 unsvg。
核心是遞迴的 eval_expr,用 match 比對 Expr。語法樹既然已經是列舉,每種敘述該如何執行,就能直接對應到一個分支。變數和使用者定義的程序,則都存在一個我稱為 context 的 HashMap<String, Expr> 裡。
eval_expr 回傳的也是 Expr。運算式求值後會得到 Expr::Constant,命令執行完沒有值,就回傳 Expr::Void。所以計算時,只要先遞迴求出左右兩邊的值,再看它們是不是兩個數字或兩個布林值就行。
用 match 同時比對左右兩邊,型別不相容的組合會直接落到最後一個分支並回報錯誤,像 + "1 "TRUE 這種就過不了。
IF 和 WHILE 會先求出條件的值,是 TRUE 就執行區塊,不是布林值就回報錯誤。WHILE 每一輪都會重新求值,所以條件中用到的變數在迴圈裡改過後,下一輪就能讀到新值。
執行程序定義 TO ... END 時,不會執行函式本體,只會把它原樣存進 context。遇到不是內建命令的名稱時,再去 context 找同名程序。找到後,先複製目前的 context 作為新的作用域,綁定參數值,再於這個作用域內執行函式本體。這樣程序內對變數的修改就不會影響外層。
畫線就更簡單了。FORWARD、BACK 這類命令算出終點座標,若筆是落下的,就用 unsvg 在兩點間畫線,再更新海龜的位置。所有錯誤都透過 Result 逐層往上傳,最後在 main 裡用 color-eyre 輸出。
儲存庫:xxxbrian/unsw-comp6991-rslogo。