1 引言
編譯器除了生成語法樹之外,還要在輸入出現錯誤時給出恰當的提示。
比如當使用者輸入 select (name
,這是個未完成的 SQL 語句,我們的目標是提示出這個語句未完成,並給出後續的建議: )
-
+
%
/
*
.
(
。
2 精讀
分析一個 SQL 語句,現將 query 字串轉成 Token 陣列,再構造文法樹解析,那麼可能出現錯誤的情況有兩種:
- 語句錯誤。
- 文法未完成。
給出錯誤提示的第一步是判斷錯誤發生。
通過這張 Token 匹配過程圖可以發現,當深度優先遍歷文法節點時,匹配成功後才會返回父元素繼續往下走。而當走到父元素沒有根節點了才算匹配成功;當嘗試 Chance 時沒有機會了,就是錯誤發生的時機。
所以我們只要找到最後一個匹配成功的節點,再根據最後成功與否,以及搜尋出下一個可能節點,就能知道錯誤型別以及給出建議了。
function onMatchNode(matchNode, store) {
const matchResult = matchNode.run(store.scanner);
if (!matchResult.match) {
tryChances(matchNode, store);
} else {
const restTokenCount = store.scanner.getRestTokenCount();
if (matchNode.matching.type !== "loose") {
if (!lastMatch) {
lastMatch = {
matchNode,
token: matchResult.token,
restTokenCount
};
}
}
callParentNode(matchNode, store, matchResult.token);
}
}
所以在執行語法分析器時,在遇到匹配節點(MatchNode
)時,如果匹配成功,就記錄下這個節點,這樣我們最終會找到最後一個匹配成功的節點:lastMatch
。
之後通過 findNextMatchNodes
函式找到下一個可能的推薦節點列表,作為錯誤恢復的建議。
findNextMatchNodes
函式會根據某個節點,找出下一節點所有可能 Tokens 列表,這個函式後面文章再專門介紹,或者你也可以先閱讀 原始碼.
語句錯誤
也就是任何一個 Token 匹配失敗。比如:
select * from table_name as table1 error_string;
這裡 error_string 就是冗餘的語句。
通過語法解析器分析,可以得到執行失敗的結果,然後通過 findNextMatchNodes
函式,我們可以得到下面分析結果:
可以看到,程式判斷出了 error_string 這個 Token 屬於錯誤型別,同時給出建議,可以將 error_string 替換成這 14 個建議字串中任意一個,都能使語句正確。
之所以失敗型別判斷為錯誤型別,是因為查詢了這個正確 Token table1
後面還有一個沒有被使用的 error_string
,所以錯誤歸類是 wrong
。
注意,這裡給出的是下一個 Token 建議,而不是全部 Token 建議,因此推薦了 where 表示 “或者後面跟一個完整的 where 語句”。
文法未完成
和語句錯誤不同,這種錯誤所有輸入的單詞都是正確的,但卻沒有寫完。比如:
select *
通過語法解析器分析,可以得到執行失敗的結果,然後通過 findNextMatchNodes
函式,我們可以得到下面分析結果:
可以看到,程式判斷出了 * 這個 Token 屬於未完成的錯誤型別,建議在後面補全這 14 個建議字串中任意一個。比較容易聯想到的是 where
,但也可以是任意子文法的未完成狀態,比如後面補充 ,
繼續填寫欄位,或者直接跟一個單詞表示別名,或者先輸入 as
再跟別名。
之所以失敗型別判斷為未完成,是因為最後一個正確 Token *
之後沒有 Token 了,但語句解析失敗,那只有一個原因,就是語句為寫完,因此錯誤歸類是 inComplete
。
找到最易讀的錯誤型別
在一開始有提到,我們只要找到最後一個匹配成功的節點,就可以順藤摸瓜找到錯誤原因以及提示,但最後一個成功的節點可能和我們人類直覺相違背。舉下面這個例子:
select a from b where a = '1' ~ -- 這裡手滑了
正常情況,我們都認為錯誤點在 ~
,而最後一個正確輸入是 '1'
。但詞法解析器可不這麼想,在我初版程式碼裡,判斷出錯誤是這樣的:
提示是 where
錯了,而且提示是 .
,有點摸不著頭腦。
讀者可能已經想到了,這個問題與文法結構有關,我們看 fromClause
的文法描述:
const fromClause = () =>
chain(
"from",
tableSources,
optional(whereStatement),
optional(groupByStatement),
optional(havingStatement)
)();
雖然實際傳入的 where
語句多了一個 ~
符號,但由於文法認為整個 whereStatement
是可選的,因此出錯後會跳出,跳到 b
的位置繼續匹配,而 顯然 groupByStatement
與 havingStatement
都不能匹配到 where
,因此編譯器認為 “不會從 b where a = '1' ~
” 開始就有問題吧?因此繼續往回追溯,從 tableName
開始匹配:
const tableName = () =>
chain([matchWord, chain(matchWord, ".", matchWord)()])();
此時第一次走的 b where a = '1' ~
路線對應 matchWord
,因此嘗試第二條路線,所以認為 where
應該換成 .
。
要解決這個問題,首先要 承認這個判斷是對的,因為這是一種 錯誤提前的情況,只是人類理解時往往只能看到最後幾步,所以我們預設使用者想要的錯誤資訊,是 正確匹配鏈路最長的那條,並對 onMatchNode
作出下面優化:
將 lastMatch
物件改為 lastMatchUnderShortestRestToken
:
if (
!lastMatchUnderShortestRestToken ||
(lastMatchUnderShortestRestToken &&
lastMatchUnderShortestRestToken.restTokenCount > restTokenCount)
) {
lastMatchUnderShortestRestToken = {
matchNode,
token: matchResult.token,
restTokenCount
};
}
也就是每次匹配到正確字元,都獲取剩餘 Token 數量,只保留最後一匹配正確 且剩餘 Token 最少的那個。
3 總結
做語法解析器錯誤提示功能時,再次重新整理了筆者三觀,原來我們以為的必然,在編譯器裡對應著那麼多 “可能”。
當我們遇到一個錯誤 SQL 時,錯誤原因往往不止一個,你可以隨便擷取一段,說是從這一步開始就錯了。語法解析器為了讓報錯符合人們的第一直覺,對錯誤資訊做了 過濾,只保留剩餘 Token 數最短的那條錯誤資訊。
4 更多討論
討論地址是:精讀《手寫 SQL 編譯器 - 錯誤提示》 · Issue #101 · dt-fe/weekly
如果你想參與討論,請點選這裡,每週都有新的主題,週末或週一釋出。