戰(zhàn)與避坑指南)
1. 為什么我最終選擇了 tree_sitter 而不是正則第一次接觸 tree_sitter 是在做一個(gè)代碼審計(jì)工具的時(shí)候。當(dāng)時(shí)的需求很樸素從幾十萬行不同語言的源碼里把函數(shù)定義、類聲明、導(dǎo)入語句這些結(jié)構(gòu)化的東西抽出來。我一開始用的是正則表達(dá)式寫了大概兩百多條規(guī)則Python 一套、JavaScript 一套、Go 一套維護(hù)起來簡直是噩夢(mèng)。改一個(gè)語言的規(guī)則另外幾個(gè)語言的匹配就莫名其妙地崩了因?yàn)檎齽t本身沒有語法層級(jí)的概念它只認(rèn)字符模式。后來同事推薦了 tree_sitter我花了一個(gè)周末把它跑通那種感覺就像從用螺絲刀擰螺絲升級(jí)到了用電鉆。tree_sitter 本質(zhì)上是一個(gè)增量式解析框架它把源碼解析成一棵具體的語法樹CST你可以像操作 DOM 一樣去遍歷這棵樹按節(jié)點(diǎn)類型精確地拿到你想要的結(jié)構(gòu)。它和正則最大的區(qū)別在于正則匹配的是文本長什么樣tree_sitter 理解的是這段代碼在語法上是什么。這個(gè)區(qū)別在實(shí)際項(xiàng)目里非常致命。舉個(gè)例子你想提取所有函數(shù)名。用正則你得考慮function foo()、const foo () 、async function foo()、類方法foo() {}等等各種寫法還得排除字符串里出現(xiàn)的function關(guān)鍵字。而 tree_sitter 直接給你一個(gè)function_declaration節(jié)點(diǎn)你取它的name字段就完事了字符串里的內(nèi)容根本不會(huì)被誤判因?yàn)樗谡Z法樹里是string節(jié)點(diǎn)壓根不在你的遍歷路徑上。tree_sitter 的另一個(gè)核心優(yōu)勢是多語言支持。它的架構(gòu)是核心庫 語言語法包分離的。核心庫負(fù)責(zé)解析算法、樹結(jié)構(gòu)管理、增量更新這些通用邏輯而每種語言的語法規(guī)則被編譯成一個(gè)獨(dú)立的動(dòng)態(tài)庫.so、.dylib或.dll。你想支持一門新語言只需要加載對(duì)應(yīng)的語法包然后調(diào)用統(tǒng)一的 API 就行。目前官方和社區(qū)維護(hù)的語法包覆蓋了 Python、JavaScript、TypeScript、Go、Rust、Java、C/C、Ruby、PHP、C#、Swift、Kotlin 等幾十種語言基本上你叫得上名字的編程語言都有。我后來把這個(gè)方案用在了好幾個(gè)項(xiàng)目里代碼搜索工具、依賴分析器、自動(dòng)化重構(gòu)腳本。實(shí)測下來解析速度比我想象的快很多一個(gè)幾千行的文件通常幾十毫秒就能解析完而且增量解析的能力意味著你改一行代碼它只需要重新解析受影響的那部分子樹而不是整個(gè)文件重來。這對(duì)于需要實(shí)時(shí)響應(yīng)的編輯器插件場景特別關(guān)鍵。如果你正在做代碼分析、IDE 插件、代碼格式化工具、靜態(tài)檢查器或者任何需要理解代碼結(jié)構(gòu)而不是匹配代碼文本的事情tree_sitter 基本是當(dāng)前最優(yōu)解之一。接下來的內(nèi)容我會(huì)把多語言解析器配置的完整流程拆開講包括環(huán)境準(zhǔn)備、語言包加載、查詢語法、遍歷策略以及我在實(shí)際配置中踩過的那些坑。2. 環(huán)境搭建核心庫與語言包的分離式安裝2.1 理解 tree_sitter 的兩層架構(gòu)很多人第一次配 tree_sitter 會(huì)懵因?yàn)樗陌惭b不像普通 Python 包那樣pip install一下就完事。你得先搞清楚它的兩層結(jié)構(gòu)核心運(yùn)行時(shí)和語言語法包。核心運(yùn)行時(shí)就是tree_sitter這個(gè)庫本身它提供了Parser、Tree、Node、Query這些基礎(chǔ)類負(fù)責(zé)解析調(diào)度和樹結(jié)構(gòu)管理。語言語法包則是每種語言單獨(dú)發(fā)布的比如tree_sitter_python、tree_sitter_javascript。它們之間的關(guān)系就像播放器和解碼器播放器只有一個(gè)但你想播什么格式就裝什么解碼器。這種設(shè)計(jì)的好處是核心庫可以保持極簡和穩(wěn)定語言包的更新互不影響。壞處是配置的時(shí)候步驟多了一層而且不同語言包的版本兼容性需要留意。2.2 Python 環(huán)境下的安裝實(shí)操我主要以 Python 環(huán)境為例因?yàn)檫@是最常用的場景。先裝核心庫pip install tree_sitter然后按需裝語言包。注意語言包的包名和導(dǎo)入名不完全一樣導(dǎo)入的時(shí)候通常去掉tree_sitter_前綴pip install tree_sitter_python tree_sitter_javascript tree_sitter_go裝完之后驗(yàn)證一下import tree_sitter import tree_sitter_python print(tree_sitter.__version__) print(tree_sitter_python.__file__)這里有個(gè)版本兼容的坑要重點(diǎn)說。tree_sitter 核心庫在 0.22 版本前后 API 有過一次比較大的調(diào)整主要是Language對(duì)象的構(gòu)造方式變了。老版本你可能直接傳一個(gè)指針新版本需要用Language(...)包裝。如果你裝的語言包版本和核心庫版本對(duì)不上運(yùn)行時(shí)會(huì)報(bào)TypeError或者直接段錯(cuò)誤。我的建議是核心庫和所有語言包盡量用同一時(shí)間段發(fā)布的版本不要一個(gè)用最新的、一個(gè)用兩年前的。2.3 語言包的加載方式加載語言包有兩種常見寫法取決于你用的版本。新版推薦這樣import tree_sitter_python as tspython from tree_sitter import Language, Parser PY_LANGUAGE Language(tspython.language()) parser Parser(PY_LANGUAGE)老版本可能是這樣from tree_sitter import Language, Parser Language.build_library( build/my-languages.so, [vendor/tree-sitter-python, vendor/tree-sitter-javascript] ) PY_LANGUAGE Language(build/my-languages.so, python) parser Parser() parser.set_language(PY_LANGUAGE)第二種方式需要你手動(dòng) clone 各個(gè)語言的源碼倉庫然后用build_library編譯成一個(gè)合并的動(dòng)態(tài)庫。這種方式在需要自定義語法或者用非官方維護(hù)的語言包時(shí)很有用但日常開發(fā)我強(qiáng)烈建議用第一種省事且不容易出錯(cuò)。提示如果你在 Windows 上編譯語言包遇到 C 編譯器缺失的問題直接裝預(yù)編譯的 pip 包就行別去折騰源碼編譯除非你有明確的定制需求。2.4 多語言共存的配置策略實(shí)際項(xiàng)目里你往往需要同時(shí)支持好幾種語言。我的做法是建一個(gè)語言注冊(cè)表把語言名和對(duì)應(yīng)的Language對(duì)象映射起來用的時(shí)候按擴(kuò)展名查表from tree_sitter import Language, Parser import tree_sitter_python as tspython import tree_sitter_javascript as tsjavascript import tree_sitter_go as tsgo LANGUAGES { .py: Language(tspython.language()), .js: Language(tsjavascript.language()), .go: Language(tsgo.language()), } def get_parser(ext): lang LANGUAGES.get(ext) if lang is None: return None return Parser(lang)這樣你只需要維護(hù)一個(gè)映射表新增語言就是加一行。注意Parser對(duì)象不是線程安全的多線程場景下每個(gè)線程要獨(dú)立創(chuàng)建自己的Parser但Language對(duì)象可以共享因?yàn)樗举|(zhì)上是只讀的語法定義。3. 語法樹遍歷從根節(jié)點(diǎn)到你要的那片葉子3.1 解析結(jié)果的基本結(jié)構(gòu)調(diào)用parser.parse(source_bytes)之后你拿到的是一個(gè)Tree對(duì)象。tree.root_node就是整棵語法樹的根通常對(duì)應(yīng)整個(gè)源文件。每個(gè)節(jié)點(diǎn)有type節(jié)點(diǎn)類型比如function_definition、start_point、end_point起止位置行列號(hào)、children子節(jié)點(diǎn)列表、named_children有名字的子節(jié)點(diǎn)過濾掉了括號(hào)逗號(hào)這類標(biāo)點(diǎn)。這里有個(gè)容易混淆的點(diǎn)children和named_children的區(qū)別。children包含所有子節(jié)點(diǎn)包括(、)、,、:這些匿名節(jié)點(diǎn)named_children只包含語法上有意義的節(jié)點(diǎn)。絕大多數(shù)情況下你用named_children就夠了除非你在做格式化工具需要精確定位標(biāo)點(diǎn)位置。source b def greet(name): return fHello, {name} tree parser.parse(source) root tree.root_node print(root.type) # module for child in root.named_children: print(child.type, child.start_point, child.end_point)3.2 遞歸遍歷與迭代遍歷的取舍遍歷語法樹有兩種方式遞歸和迭代。遞歸寫起來直觀但遇到超深嵌套的代碼比如自動(dòng)生成的、層層包裹的表達(dá)式可能會(huì)爆棧。迭代用顯式棧安全但代碼啰嗦。我一般用遞歸但會(huì)加一個(gè)深度上限保護(hù)def walk(node, depth0, max_depth200): if depth max_depth: return yield node for child in node.named_children: yield from walk(child, depth 1, max_depth)這個(gè)walk生成器可以讓你用for node in walk(root)的方式扁平地遍歷所有節(jié)點(diǎn)配合if node.type function_definition就能篩選出目標(biāo)節(jié)點(diǎn)。實(shí)測下來對(duì)于正常手寫的代碼深度很少超過 50 層200 的上限足夠安全。3.3 用字段名精確取子節(jié)點(diǎn)光靠遍歷篩選有時(shí)候不夠精確。比如一個(gè)函數(shù)定義節(jié)點(diǎn)它的名字、參數(shù)、返回值、函數(shù)體都是子節(jié)點(diǎn)你怎么知道哪個(gè)是哪個(gè)tree_sitter 提供了**字段field**機(jī)制你可以用child_by_field_name直接按語義取for node in walk(root): if node.type function_definition: name_node node.child_by_field_name(name) params_node node.child_by_field_name(parameters) body_node node.child_by_field_name(body) print(name_node.text.decode(utf-8))字段名是每種語言的語法定義里規(guī)定的不同語言不一樣。Python 里函數(shù)名是name參數(shù)是parametersJavaScript 里函數(shù)聲明也是name和parameters但箭頭函數(shù)的處理略有不同。查字段名最靠譜的辦法是去看對(duì)應(yīng)語言語法倉庫里的grammar.js或者直接用node.field_name_for_child(i)在運(yùn)行時(shí)探測。3.4 處理解析錯(cuò)誤節(jié)點(diǎn)tree_sitter 的一個(gè)強(qiáng)大之處是容錯(cuò)解析。即使源碼有語法錯(cuò)誤它也不會(huì)直接拋異常而是把無法解析的部分標(biāo)記成ERROR節(jié)點(diǎn)其余部分照常解析。這對(duì)處理不完整代碼比如用戶正在編輯器里敲的代碼特別重要。def find_errors(node): if node.type ERROR or node.is_missing: print(fError at {node.start_point}: {node.text[:50]}) for child in node.children: find_errors(child)is_missing表示這個(gè)節(jié)點(diǎn)是解析器補(bǔ)出來的源碼里其實(shí)沒有。比如你寫了個(gè)if但沒寫條件解析器會(huì)補(bǔ)一個(gè)缺失的條件節(jié)點(diǎn)。做靜態(tài)檢查工具的時(shí)候這些錯(cuò)誤節(jié)點(diǎn)本身就是有價(jià)值的信號(hào)。4. 查詢語法用 S-expression 精準(zhǔn)捕獲目標(biāo)節(jié)點(diǎn)4.1 為什么需要查詢語法遍歷加字段名的方式已經(jīng)能解決大部分問題但當(dāng)你需要匹配某種特定模式的節(jié)點(diǎn)組合時(shí)手寫遍歷邏輯會(huì)變得很啰嗦。比如你想找所有調(diào)用了print函數(shù)的語句用遍歷你得先找call節(jié)點(diǎn)再檢查它的function字段是不是identifier且文本是print。而用 tree_sitter 的查詢語法一行就能表達(dá)(call function: (identifier) func_name (#eq? func_name print))這就是S-expression 查詢tree_sitter 內(nèi)置的模式匹配語言。它讓你用聲明式的方式描述我要什么樣的節(jié)點(diǎn)結(jié)構(gòu)比命令式的遍歷代碼清晰得多。4.2 查詢的基本寫法創(chuàng)建查詢對(duì)象需要語言和查詢字符串from tree_sitter import Query, QueryCursor query_str (function_definition name: (identifier) func.name parameters: (parameters) func.params) query Query(PY_LANGUAGE, query_str) cursor QueryCursor(query) captures cursor.captures(root)captures返回的是一個(gè)字典鍵是捕獲名后面的名字值是對(duì)應(yīng)的節(jié)點(diǎn)列表。你可以給同一個(gè)模式里的不同部分起不同的捕獲名方便后續(xù)區(qū)分處理。4.3 常用謂詞與捕獲技巧查詢語法支持一些內(nèi)置謂詞來做條件過濾常用的有謂詞作用示例#eq?文本相等(#eq? name main)#match?正則匹配(#match? name ^test_)#any-of?多值匹配(#any-of? name foo bar)比如找所有以test_開頭的函數(shù)(function_definition name: (identifier) test_func (#match? test_func ^test_))捕獲名用點(diǎn)號(hào)分隔如func.name是個(gè)好習(xí)慣它讓你在代碼里能一眼看出這個(gè)捕獲屬于哪個(gè)邏輯分組。另外后面跟下劃線開頭的名字如_ignored表示這個(gè)捕獲你不想在結(jié)果里看到只是用來做結(jié)構(gòu)約束的。4.4 查詢性能與緩存查詢對(duì)象創(chuàng)建是有成本的因?yàn)樗?S-expression 編譯成內(nèi)部的狀態(tài)機(jī)。不要每次解析都重新創(chuàng)建 Query應(yīng)該在初始化時(shí)創(chuàng)建一次然后復(fù)用。QueryCursor倒是可以每次新建它只是遍歷狀態(tài)。# 初始化時(shí)創(chuàng)建一次 QUERY Query(PY_LANGUAGE, query_str) def analyze(tree): cursor QueryCursor(QUERY) return cursor.captures(tree.root_node)我在一個(gè)批量分析幾萬個(gè)文件的項(xiàng)目里把 Query 提到模塊級(jí)別緩存后整體耗時(shí)下降了大概 30%。這個(gè)優(yōu)化很廉價(jià)但收益明顯。5. 多語言配置中的那些坑與應(yīng)對(duì)5.1 語言包版本與核心庫不匹配這是最常見的問題。表現(xiàn)是導(dǎo)入語言包時(shí)報(bào)AttributeError: module has no attribute language或者創(chuàng)建Language對(duì)象時(shí)崩潰。根因是語言包的 API 和核心庫的 API 對(duì)不上。排查方法先看核心庫版本pip show tree_sitter再看語言包版本pip show tree_sitter_python然后去對(duì)應(yīng)倉庫的 release notes 確認(rèn)兼容區(qū)間。我的經(jīng)驗(yàn)是核心庫和語言包都鎖在同一個(gè)大版本內(nèi)比如都用 0.21.x 或都用 0.22.x不要混。5.2 字節(jié)與字符串的編碼陷阱tree_sitter 的parse方法接受的是bytes不是 str。如果你傳了 str會(huì)報(bào)類型錯(cuò)誤。而且節(jié)點(diǎn)位置start_point、end_point里的列號(hào)是按字節(jié)算的不是按字符算的。這意味著如果你處理的是中文源碼或者注釋列號(hào)會(huì)和你在編輯器里看到的對(duì)不上。source def 函數(shù)(): pass tree parser.parse(source.encode(utf-8)) # 必須編碼 node tree.root_node # node.start_point 的列號(hào)是字節(jié)偏移不是字符偏移處理辦法如果你需要精確的字符位置得自己用字節(jié)偏移去原始 bytes 里切片再解碼成 str 來算字符數(shù)。做編輯器插件的時(shí)候這個(gè)細(xì)節(jié)特別重要否則光標(biāo)定位會(huì)偏。5.3 增量解析的正確用法增量解析是 tree_sitter 的招牌功能但用錯(cuò)了反而更慢。核心是tree.edit()和parser.parse(..., old_tree...)的配合# 假設(shè)你在某個(gè)位置插入了一段文本 edit tree.edit( start_byte10, old_end_byte10, new_end_byte25, start_point(0, 10), old_end_point(0, 10), new_end_point(0, 25), ) new_tree parser.parse(new_source, old_treetree)關(guān)鍵點(diǎn)你必須先調(diào)用tree.edit()告訴樹哪里變了再把它作為old_tree傳進(jìn)去。如果你跳過edit直接傳舊樹解析器會(huì)以為源碼沒變返回的樹就是錯(cuò)的。這個(gè)坑我踩過一次調(diào)試了半天才發(fā)現(xiàn)是漏了edit調(diào)用。5.4 不同語言的節(jié)點(diǎn)類型差異多語言項(xiàng)目里最容易犯的錯(cuò)是假設(shè)不同語言的節(jié)點(diǎn)類型名一樣。實(shí)際上差異很大概念PythonJavaScriptGo函數(shù)定義function_definitionfunction_declarationfunction_declaration類定義class_definitionclass_declaration無用 struct導(dǎo)入import_statementimport_statementimport_declaration變量聲明assignmentvariable_declaratorshort_var_declaration所以你的分析邏輯不能寫死節(jié)點(diǎn)類型得按語言做映射。我的做法是維護(hù)一個(gè)概念到節(jié)點(diǎn)類型的映射表每種語言一份分析代碼只認(rèn)概念不認(rèn)具體類型名。5.5 內(nèi)存管理與大樹處理解析超大文件比如幾萬行的自動(dòng)生成代碼時(shí)語法樹會(huì)占用大量內(nèi)存。tree_sitter 的樹節(jié)點(diǎn)是 C 結(jié)構(gòu)Python 層只是包裝但如果你把所有節(jié)點(diǎn)都存到 Python 列表里內(nèi)存還是會(huì)爆。處理策略流式處理不要一次性收集所有節(jié)點(diǎn)。用生成器遍歷處理完一個(gè)節(jié)點(diǎn)就丟棄引用。如果確實(shí)需要保留只保留你關(guān)心的字段比如函數(shù)名和位置不要保留整個(gè)節(jié)點(diǎn)對(duì)象。def extract_functions(tree): results [] for node in walk(tree.root_node): if node.type function_definition: name node.child_by_field_name(name) results.append({ name: name.text.decode(utf-8), start: node.start_point, end: node.end_point, }) return results這樣存的是純 Python 字典不持有節(jié)點(diǎn)引用內(nèi)存占用可控。6. 一套可復(fù)用的多語言解析器封裝6.1 設(shè)計(jì)目標(biāo)與接口把前面所有東西整合起來我封裝了一個(gè)MultiLangParser類目標(biāo)是給一個(gè)文件路徑自動(dòng)識(shí)別語言、解析、返回語法樹給一個(gè)查詢字符串自動(dòng)用對(duì)應(yīng)語言的查詢?nèi)テヅ?。接口盡量簡單class MultiLangParser: def __init__(self, lang_map): self.lang_map lang_map # {ext: Language} self.parsers {} # 每個(gè)語言一個(gè) Parser self.queries {} # 查詢緩存 def parse_file(self, path): ext os.path.splitext(path)[1] lang self.lang_map.get(ext) if lang is None: return None if ext not in self.parsers: self.parsers[ext] Parser(lang) with open(path, rb) as f: source f.read() return self.parsers[ext].parse(source), source注意Parser是按語言緩存的因?yàn)橐粋€(gè)Parser實(shí)例同一時(shí)間只能綁定一種語言。切換語言要么換 Parser要么調(diào)set_language但頻繁切換有開銷緩存更劃算。6.2 查詢的按語言隔離查詢字符串是跟語言綁定的Python 的查詢不能拿去查 JavaScript。所以緩存查詢時(shí)要以語言為鍵def query(self, ext, query_str): key (ext, query_str) if key not in self.queries: lang self.lang_map[ext] self.queries[key] Query(lang, query_str) return self.queries[key]這樣同一個(gè)查詢字符串在不同語言下會(huì)各自編譯一份互不干擾。6.3 實(shí)際使用示例假設(shè)我要統(tǒng)計(jì)一個(gè)項(xiàng)目里所有 Python 和 JavaScript 文件的函數(shù)數(shù)量lang_map { .py: Language(tspython.language()), .js: Language(tsjavascript.language()), } mp MultiLangParser(lang_map) py_query (function_definition name: (identifier) name) js_query (function_declaration name: (identifier) name) for root_dir, _, files in os.walk(src): for f in files: path os.path.join(root_dir, f) ext os.path.splitext(f)[1] if ext not in lang_map: continue tree, source mp.parse_file(path) q mp.query(ext, py_query if ext .py else js_query) cursor QueryCursor(q) captures cursor.captures(tree.root_node) names [n.text.decode(utf-8) for n in captures.get(name, [])] print(f{path}: {len(names)} functions)這段代碼可以直接拿去改改用。核心思路就是語言映射表 Parser 緩存 Query 緩存 統(tǒng)一的遍歷接口。6.4 擴(kuò)展新語言的步驟想加一門新語言比如 Rust步驟就三步裝包pip install tree_sitter_rust在lang_map里加一行.rs: Language(tsrust.language())然后寫對(duì)應(yīng)的查詢字符串。不需要改任何核心邏輯。這就是 tree_sitter 架構(gòu)設(shè)計(jì)的價(jià)值所在——語言是可插拔的。7. 一些實(shí)戰(zhàn)中攢下來的經(jīng)驗(yàn)配置 tree_sitter 這件事文檔能告訴你的東西有限很多細(xì)節(jié)都是踩坑踩出來的。我挑幾個(gè)印象最深的說說。第一個(gè)是關(guān)于查詢字符串的調(diào)試。S-expression 寫錯(cuò)了不會(huì)給你友好的報(bào)錯(cuò)往往就是一個(gè)QueryError帶個(gè)模糊的位置。我的辦法是先用最簡單的查詢跑通比如就寫(identifier) id確認(rèn)能匹配到東西再逐步加約束。別一上來就寫復(fù)雜的嵌套模式出錯(cuò)了你根本不知道是哪一層的問題。第二個(gè)是關(guān)于節(jié)點(diǎn)文本的獲取。node.text返回的是 bytes而且它是從原始 source 里切出來的。如果你在增量解析后 source 變了舊節(jié)點(diǎn)的text可能就不對(duì)了。所以要么在解析后立刻提取你需要的信息要么保留對(duì)應(yīng)的 source 快照。我一般是在解析完馬上把關(guān)心的文本 decode 出來存好。第三個(gè)是關(guān)于性能的直覺。tree_sitter 的解析本身很快但 Python 層的節(jié)點(diǎn)遍歷有開銷。如果你要遍歷整棵樹找特定節(jié)點(diǎn)用 Query 通常比手寫 Python 遍歷快因?yàn)?Query 的匹配是在 C 層做的。我做過對(duì)比同樣一個(gè)找所有函數(shù)定義的任務(wù)Query 方式比 Python 遞歸遍歷快大概 2 到 3 倍。所以能用 Query 就用 Query。第四個(gè)是關(guān)于錯(cuò)誤處理的心態(tài)。tree_sitter 的容錯(cuò)解析意味著它幾乎不會(huì)失敗但不失敗不等于結(jié)果正確。有語法錯(cuò)誤的文件解析出來的樹可能缺胳膊少腿。做分析工具的時(shí)候你得決定是跳過有錯(cuò)誤的文件還是帶著錯(cuò)誤繼續(xù)分析。我的選擇是繼續(xù)分析但把錯(cuò)誤位置記錄下來讓用戶知道結(jié)果可能不完整。最后說個(gè)配置層面的建議把語言包版本寫進(jìn) requirements.txt 并鎖定。tree_sitter 生態(tài)更新挺快的語言包的 API 偶爾會(huì)變。你今天跑通的代碼過兩個(gè)月在新環(huán)境里pip install可能就崩了。鎖定版本能省掉很多莫名其妙的排查時(shí)間。