20260630 Zed 編輯器:很快,但代價是什麼?
Published:Updated:
好啦這篇的內容沒有標題看的厲害,我只是又想隨筆寫寫 Zed 而已。起因是最近 VS Code 更新瘋狂塞 AI 功能,偶爾的卡又讓我想第三度回去試 Zed,試了一下的感想是:
把 discussion 搬到 Discord 到底是啥意思??
這代表以後別想在網路上搜尋到 Zed 的問題了,我是真心傻眼。
總是愛抱怨
- 預設主題超低對比度,視力 2.0 是不是
- 我記得舊版的程式中間和 VS Code 一樣有功能框可按,現在沒了,還我滑鼠操作
- Zed Log 可以看不能改,還鎖 reveal in finder,到底有啥毛病?
- 檔案在外部更新,editor 裡面不會及時更新。比如說你在開發擴充功能,rebuild 之後要手動重開一次 Log 才能刷新,我現在是在 2005 嗎?
- 預設「無限自動安裝」HTML 擴充功能,這真的該用髒話伺候,不是和 VS Code 一樣會自動安裝但是你移除掉就沒了,這個自動安裝是每次重開他都會自動安裝,超蠢,剛好我在開發 HTML 擴充功能還以為是不是自己哪裡搞錯浪費我一堆時間。
- 不支援 workspace enable/disable extension,超爛,兩個專案的插件絕對不能撞到
- 我只是想要 collapse expanded directories,但是他不想讓我用滑鼠按,強迫我用 cmd+shift+p+指令,或是自己寫 keybinding 快捷鍵
- 上方的檔案標籤沒有圖示只有純文字,沒有檔案語言圖標,也沒有 Git status,大缺點
- Zed 的 extension 架構完全做不了 UI 相關的功能,git graph、markdown CMS 這類以畫面互動為主的 extension 目前完全不可能
我理解有些缺點需要很大的努力才能完成,畢竟是 Rust 的限制,快總是有代價,但是最讓我不想用的就是把 discussion 搬到 Discord,不讓用戶在網路上討論,讓 Google 完全找不到相關資訊真的超糟糕。
有些所謂的「原生派」很討厭 Electron 應用,但是如果用在對的地方 Electron 也不慢,更別忘了 Electron 實際上也是 C/C++ 寫出來的阿。
語言插件開發
Zed 內建支援語言插件,和 VS Code 基於 regex 的 text-mate 不同,tree-sitter 是真的強,能理解多行程式的語意,也能作為 LSP 使用,相較於只有 regex、只理解單行的 text-mate 真的是降維打擊,不過這也有好有壞,文章後面會講到。
我看到 Zed 沒有任何 Hugo 相關生態支援,我就想說那我自己來弄一個語言插件支援吧感覺就很酷,結果問題來了,tree-sitter 有兩種方案可以做到這個:
- 基於 HTML,inject go-template 語法
- 反過來基於 go-template
還考察了其他模板語言怎麼做的,以 Jinja 插件為例,有兩個:
- ArcherHume/jinja2-support:Jinja2 裡有 HTML
- JaagupAverin/html-jinja:HTML 裡有 Jinja2
剛好就對應到兩種做法。實際上 Jinja2 裡有 HTML 很糟,因為這樣要設定整個 HTML/CSS/JS 的 injections,變成你要自己維護一份 HTML tree-sitter,反過來說 HTML 裡面有 Jinja2,那就只需要做到在 HTML 裡面辨識 Jinja 節點就好了,HTML 部分由已經成熟的生態系負責,自己只須維護相對小眾的 Jinja。
注意到了嗎?兩種方案無論如何都需要自己維護一份 HTML+Jinja 的 tree-sitter,只是主從問題而已,實際上兩個專案也都自己 fork 了一份 tree-sitter (分別是 https://github.com/ArcherHume/tree-sitter-jinja2 和 https://github.com/JaagupAverin/tree-sitter-html),也就是說要建立 Hugo 的語言插件一定要自己 fork tree-sitter-html 讓他支援 go-template 才行,這我可不做,工程太大了。
簡易版做法
那如果不 fork tree-sitter-html 能做到嗎?當然也可以只是效果不好,這裡請 Claude 表演做了一個簡易版的 tree-sitter Hugo HTML,tree-sitter-html 完全不理解 go-template 節點,只是把 text 全部丟給 go-template 處理而已。
以下短短十一個檔案你就已經做好一個簡易版的 tree-sitter 語言了喔,複製完然後在 extension 選 Install Dev Extension 再選這個目錄就可用了,他會自動幫你拉依賴、build wasm。
extension.toml
id = "hugo-syntax-highlighter"
name = "Hugo Syntax Highlighter"
description = "Syntax highlighting for Hugo templates (.html partial files with Go template syntax)."
version = "0.1.0"
schema_version = 1
authors = ["Hugo Extension Author"]
repository = "https://github.com/example/hugo-syntax-highlighter"
[grammars.html]
repository = "https://github.com/tree-sitter/tree-sitter-html"
rev = "bfa075d83c6b97cd48440b3829ab8d24a2319809"
[grammars.gotmpl]
repository = "https://github.com/ngalaiko/tree-sitter-go-template"
rev = "aa71f63de226c5592dfbfc1f29949522d7c95fac"
langauges/html/brackets.scm
(("<" @open
"/>" @close)
(#set! rainbow.exclude))
(("<" @open
">" @close)
(#set! rainbow.exclude))
(("</" @open
">" @close)
(#set! rainbow.exclude))
(("\"" @open
"\"" @close)
(#set! rainbow.exclude))
((element
(start_tag) @open
(end_tag) @close)
(#set! newline.only)
(#set! rainbow.exclude))
languages/html/config.toml
name = "HTML"
grammar = "html"
path_suffixes = ["html", "htm", "shtml"]
autoclose_before = ">})"
block_comment = { start = "<!--", prefix = "", end = "-->", tab_size = 0 }
wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "</", end_suffix = ">" }
brackets = [
{ start = "{", end = "}", close = true, newline = true },
{ start = "[", end = "]", close = true, newline = true },
{ start = "(", end = ")", close = true, newline = true },
{ start = "\"", end = "\"", close = true, newline = false, not_in = ["comment", "string"] },
{ start = "<", end = ">", close = false, newline = true, not_in = ["comment", "string"] },
{ start = "!--", end = " --", close = true, newline = false, not_in = ["comment", "string"] },
]
completion_query_characters = ["-"]
prettier_parser_name = "html"
[overrides.default]
linked_edit_characters = ["-"]
languages/html/highlights.scm
(tag_name) @tag
(doctype) @tag.doctype
(attribute_name) @attribute
[
"\""
"'"
(attribute_value)
] @string
(comment) @comment
(entity) @string.special
"=" @punctuation.delimiter
[
"<"
">"
"<!"
"</"
"/>"
] @punctuation.bracket
languages/html/indents.scm
(start_tag
">" @end) @indent
(self_closing_tag
"/>" @end) @indent
(element
(start_tag) @start
(end_tag)? @end) @indent
langauges/html/injections.scm
; Inject comment language into HTML comments
((comment) @injection.content
(#set! injection.language "comment"))
; Inject JavaScript into <script> tags
(script_element
(raw_text) @injection.content
(#set! injection.language "javascript"))
; Inject CSS into <style> tags
(style_element
(raw_text) @injection.content
(#set! injection.language "css"))
; Inject CSS into inline style attributes
(attribute
(attribute_name) @_attribute_name
(#match? @_attribute_name "^style$")
(quoted_attribute_value
(attribute_value) @injection.content)
(#set! injection.language "css"))
; Inject JavaScript into on* event handler attributes
(attribute
(attribute_name) @_attribute_name
(#match? @_attribute_name "^on[a-z]+$")
(quoted_attribute_value
(attribute_value) @injection.content)
(#set! injection.language "javascript"))
; ── Hugo / Go Template injection ──────────────────────────────────────────────
; Each `text` node is independently injected into gotmpl.
; No injection.combined: each node is parsed individually so byte offsets
; map correctly back to the source file, fixing variable/keyword highlighting.
((text) @injection.content
(#set! injection.language "gotmpl"))
; Attribute values containing gotmpl (e.g. href="{{ .RelPermalink }}")
(attribute
(quoted_attribute_value
(attribute_value) @injection.content)
(#set! injection.language "gotmpl"))
(attribute
(attribute_value) @injection.content
(#set! injection.language "gotmpl"))
langauges/html/outline.scm
(comment) @annotation
(element
(start_tag
(tag_name) @name)) @item
langauges/html/overrides.scm
(comment) @comment
(quoted_attribute_value) @string
[
(start_tag)
(end_tag)
] @default
langauges/gotmpl/brackets.scm
(("{{" @open
"}}" @close)
(#set! rainbow.bracket))
(("{{-" @open
"}}" @close)
(#set! rainbow.bracket))
(("{{" @open
"-}}" @close)
(#set! rainbow.bracket))
(("(" @open
")" @close)
(#set! rainbow.bracket))
langauges/gotmpl/config.toml
name = "gotmpl"
grammar = "gotmpl"
path_suffixes = ["gotmpl", "gohtml", "gohtmltmpl"]
block_comment = { start = "{{/*", prefix = "", end = "*/}}", tab_size = 0 }
brackets = [
{ start = "{{", end = "}}", close = true, newline = false },
{ start = "(", end = ")", close = true, newline = false },
]
langauges/gotmpl/highlights.scm
; ── Variables ─────────────────────────────────────────────────────────────────
; Capture the name field inside variable node to override child identifier
(variable
name: (identifier) @variable)
; Also capture the $ sigil as part of variable
(variable) @variable
; ── Fields & Properties ───────────────────────────────────────────────────────
[
(field)
(field_identifier)
] @property
(selector_expression
operand: (identifier) @type)
; ── Function calls ────────────────────────────────────────────────────────────
(function_call
function: (identifier) @function)
(method_call
method: (selector_expression
field: (field_identifier) @function))
; ── Operators ─────────────────────────────────────────────────────────────────
"|" @operator
":=" @operator
; ── Go Template built-in functions ───────────────────────────────────────────
((identifier) @function
(#match? @function "^(and|call|html|index|slice|js|len|not|or|print|printf|println|urlquery|eq|ne|lt|le|gt|ge)$"))
; ── Hugo-specific built-in functions ─────────────────────────────────────────
((identifier) @function
(#match? @function "^(partial|partialCached|template|block|safeHTML|safeHTMLAttr|safeCSS|safeJS|safeURL|safeJSStr|humanize|markdownify|plainify|truncate|substr|split|replace|replaceRE|findRE|findRESubmatch|trim|lower|upper|title|urlize|absURL|relURL|absLangURL|relLangURL|ref|relref|jsonify|unmarshal|readFile|readDir|dict|merge|append|apply|where|first|last|after|shuffle|delimit|reverse|sort|sortStable|group|uniq|union|intersect|complement|in|symdiff|isset|default|empty|coalesce|seq|now|time|dateFormat|duration|format|add|sub|mul|div|mod|modBool|int|float|string|warnf|warnidf|errorf|erroridf|getenv|cond)$"))
; ── Delimiters ────────────────────────────────────────────────────────────────
"." @punctuation.delimiter
"," @punctuation.delimiter
"{{" @punctuation.bracket
"}}" @punctuation.bracket
"{{-" @punctuation.bracket
"-}}" @punctuation.bracket
")" @punctuation.bracket
"(" @punctuation.bracket
; ── Hugo / Go Template Keywords ──────────────────────────────────────────────
"else" @keyword
"if" @keyword
"range" @keyword
"with" @keyword
"end" @keyword
"template" @keyword
"define" @keyword
"block" @keyword
; ── nil/bool ──────────────────────────────────────────────────────────────────
(nil) @constant.builtin
[
(true)
(false)
] @constant.builtin
; ── Literals ──────────────────────────────────────────────────────────────────
[
(interpreted_string_literal)
(raw_string_literal)
(rune_literal)
] @string
(escape_sequence) @string.escape
[
(int_literal)
(float_literal)
(imaginary_literal)
] @number
; ── Comments ──────────────────────────────────────────────────────────────────
(comment) @punctuation
; ── Errors ────────────────────────────────────────────────────────────────────
(ERROR) @punctuation
簡易版的缺點
顯而易見:HTML 不理解 go-template 節點因此很多錯誤,比如
// 1: HTML attribute
{{ i18n "foo.bar" | default "foo" }}
<div aria-label="{{ i18n "foo.bar" | default "foo" }}"></div>
// 2: Style tag
{{ if $a }}red{{ else }}blue{{ end }}
width: {{ if site.Params.foo }}10{{ else }}20{{ end }};
<style>
.foo {
color: {{ if $a }}red{{ else }}blue{{ end }};
width: {{ if site.Params.foo }}10{{ else }}20{{ end }};
}
</style>
以人肉 LSP 一看就知道 {{ }} 裡面的東西語意是相同的,因此最終上色結果也會相同,然而在 tree-sitter-html 的眼裡,HTML attribute 裡面的 go-template 只是純文字,和放在外層經由 tree-sitter-go-template 處理的 go-template 結果肯定不同,style/script 標籤也是如此,因此完全不可用。
語法上色
中間一度搞了很久想說為什麼很多節點沒顏色,結果是不同 theme 有不同顏色設計,完全忘了主題也會影響顏色,請注意 Zed 的 Github color theme 顏色超少,開發時記得不要用這個主題。
VS Code 的優點
雖然看過很多抱怨 text mate 不可能用 regex 寫出正確的 grammar,但是在這裡就相對好用多了,反正 template 不需要多準只要讓他辨識到 {{ 或 }} 後面的再處理就好了,可以簡單的作半套,而 tree-sitter 方案就真的要去 fork 源碼加入 go-template 節點才行,只有 0 和 90 沒有中間。
VS Code 的半套非常容易做到,請見 hugo.tmLanguage.json。
Zed 的缺點
因為 Zed 的語言擴充功能一定要 tree-sitter,不可能像 tmLanguage.json 可以用「插入」的方式 highlight,搭配上不能每個 workspace 自己一套 extension 就變得超蠢...