Skip to main content

ZSL

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

Published:
Updated:

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

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

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

總是愛抱怨

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

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

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

剛好就對應到兩種做法。實際上 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-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 沒有中間。

VS Code 的半套非常容易做到,請見 hugo.tmLanguage.json

Zed 的缺點

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