Zed 編輯器:很快,但代價是什麼?

好啦這篇的內容沒有標題看的厲害,我只是又想隨筆寫寫 Zed 而已。起因是最近 VS Code 更新瘋狂塞 AI 功能,越來越卡又讓我想三度回去試 Zed,試了一下的感想是:

把 discussion 搬到 Discord 到底是啥意思??

這代表以後別想在網路上搜尋到 Zed 的問題了,我是真心傻眼。

總是愛抱怨

  1. 預設主題對比度超低,視力 2.0 是不是
  2. 我記得舊版的程式中間和 VS Code 一樣有功能框可按,現在沒了,還我滑鼠操作
  3. 同樣的不給滑鼠操作:我只是想要 collapse expanded directories,但是根本沒做這個按鈕,強迫我用 cmd+shift+p+指令,或是自己寫 keybinding 快捷鍵
  4. Zed Log 可以看不能改,還鎖 reveal in finder,到底有啥毛病?
  5. 檔案在外部更新,editor 裡面不會即時更新。比如說你在開發擴充功能,rebuild 之後要手動重開一次 Log 才能刷新,我現在是在 2005 嗎?
  6. 預設「無限自動安裝」HTML 擴充功能,這真的該用髒話伺候,不是和 VS Code 一樣會自動安裝但是你移除掉就沒了,這個自動安裝是每次重開他都會自動安裝,超蠢,我又剛好在開發 HTML 擴充功能,還以為是不是自己哪裡搞錯浪費一堆時間。
  7. 不支援 workspace enable/disable extension,超爛,兩個專案的插件絕對不能撞到
  8. 上方的檔案標籤沒有圖示只有純文字,沒有檔案語言圖標,也沒有 Git status,大缺點
  9. Markdown 沒辦法 collapse section,就是在 editor 摺疊函式的功能 MD 不支援
  10. Zed 的 extension 架構完全做不了 UI 相關的功能,git graph、markdown CMS 這類以畫面互動為主的 extension 目前完全不可能

我理解有些缺點需要很大的努力才能完成,畢竟是 Rust 的限制,快總是有代價,但是最讓我不想用的就是把 discussion 搬到 Discord,不讓用戶在網路上討論,讓 Google 完全找不到相關資訊真的超糟糕。

語言插件開發

Zed 內建支援語言插件,和 VS Code 基於 regex 的 text-mate 不同,tree-sitter 是真的強,能理解多行程式的語意,也能作為 LSP 使用,相較於只有 regex、只理解單行的 text-mate 真的是降維打擊,不過這也有好有壞,文章後面會講到。

我看到 Zed 沒有任何 Hugo 相關生態支援,我就想說那我自己來弄一個語言插件支援吧感覺就很酷,結果問題來了,tree-sitter 有兩種方案可以做到這個:

  1. 基於 HTML,inject go-template 語法
  2. 反過來基於 go-template

還考察了其他模板語言怎麼做的,以 Jinja 插件為例,有兩個:

剛好就對應到兩種做法,而兩種方案無論如何都需要自己維護一份 HTML+Jinja 的 tree-sitter,只是主從問題而已,實際上兩個專案也都自己 fork 了一份 tree-sitter (分別是 https://github.com/ArcherHume/tree-sitter-jinja2https://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 沒有中間。

text mate 的半套請見 hugo.tmLanguage.json

Zed 的缺點

因為 Zed 的語言擴充功能一定要 tree-sitter,不可能像 tmLanguage.json 可以用「插入」的方式 highlight,搭配上不能每個 workspace 自己一套 extension 就變得超蠢...