<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>AST on Tarragon</title><link>https://tarrragon.github.io/blog/tags/ast/</link><description>Recent content in AST on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 24 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/ast/index.xml" rel="self" type="application/rss+xml"/><item><title>什麼是 AST — 從字串到語法樹的視角轉換</title><link>https://tarrragon.github.io/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/</guid><description>&lt;h2 id="為什麼會碰到這個詞">為什麼會碰到這個詞&lt;/h2>
&lt;p>最初的問題很小：blog 文章數量成長後，每次 commit 都會收到 markdownlint 的同類警告反覆出現。有三個代表性的：&lt;/p>
&lt;ul>
&lt;li>&lt;code>MD034/no-bare-urls&lt;/code> — 裸 URL 散落在段落與表格。&lt;/li>
&lt;li>&lt;code>MD024/no-duplicate-heading&lt;/code> — 平行結構章節（例如 13 個案例各自有 &lt;code>### 弱點環節&lt;/code>）全部被判重複。&lt;/li>
&lt;li>&lt;code>MD060/table-column-style&lt;/code> — 表格管線前後空白不一致。&lt;/li>
&lt;/ul>
&lt;p>前兩個用現成工具 &lt;code>--fix&lt;/code> 不一定修得乾淨，因為 &lt;code>MD024&lt;/code> 的「重複」在我們的語境下是&lt;strong>合法的平行結構&lt;/strong>（不同父標題下重名其實是特色），而「裸 URL 轉換」要處理表格儲存格、程式碼區塊等特殊情境，單純 regex 會誤判。&lt;/p>
&lt;p>討論到後來關鍵字出現：&lt;strong>要做得精確，可能要用 AST 工具，而不是 regex 工具&lt;/strong>。&lt;/p>
&lt;p>那麼 AST 到底是什麼？跟我們熟悉的 regex / 字串處理差在哪？&lt;/p>
&lt;h2 id="regex-工具看世界的方式字元序列">Regex 工具看世界的方式：字元序列&lt;/h2>
&lt;p>Regex 工具處理 markdown 的方式是「逐行掃描 + pattern matching」。它看到的是字元流，沒有語法結構的概念。&lt;/p>
&lt;p>舉個例子：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-markdown" data-lang="markdown">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="gu">## 【案例一】Uber 2022
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="gu">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">事件中攻擊者取得 &lt;span class="sb">`session_token`&lt;/span>，參考：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">https://www.uber.com/newsroom/security-update/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">| 事件 | 來源 |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">| --------- | ---------------- |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">| Uber 2022 | https://uber.com |&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Regex 工具看到的是：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">Line 1: &amp;#34;## 【案例一】Uber 2022&amp;#34; ← ^#{1,6} match → heading
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">Line 3: &amp;#34;事件中攻擊者取得 `session_token`...&amp;#34; ← 無 pattern 命中
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">Line 4: &amp;#34;https://www.uber.com/...&amp;#34; ← ^https?:// match → bare URL!
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">Line 7: &amp;#34;| Uber 2022 | https://uber.com |&amp;#34; ← ^\| match → table row&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每一行獨立判讀，沒有上下文。Regex 工具不知道 line 4 到底是「段落的一部分」、「引用區塊裡的連結」、還是「程式碼範例」。它只看 pattern。&lt;/p>
&lt;h2 id="ast-工具看世界的方式語法樹">AST 工具看世界的方式：語法樹&lt;/h2>
&lt;p>AST = Abstract Syntax Tree，抽象語法樹。AST 工具先把整段 markdown 用 parser &lt;strong>解析成結構化的樹&lt;/strong>，然後工具在樹上走訪（traverse），操作「節點」而不是「行」。&lt;/p>
&lt;p>同一段 markdown，goldmark（Hugo 內建的 markdown parser）解析後的樹大致是：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">Document
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">├── Heading (level=2)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">│ └── Text: &amp;#34;【案例一】Uber 2022&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">├── Paragraph
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">│ ├── Text: &amp;#34;事件中攻擊者取得 &amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">│ ├── CodeSpan: &amp;#34;session_token&amp;#34; ← 知道這是 inline code
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">│ ├── Text: &amp;#34;，參考：&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">│ └── AutoLink: &amp;#34;https://www.uber.com/...&amp;#34; ← 知道這是段落中的裸 URL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">└── Table
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> ├── TableHeader: [事件, 來源]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> └── TableRow
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> ├── TableCell: &amp;#34;Uber 2022&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> └── TableCell: AutoLink &amp;#34;https://uber.com&amp;#34; ← 知道這是表格儲存格中的 URL&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>對同一個 URL，AST 工具能分辨「它在段落裡」還是「它在表格儲存格裡」還是「它在程式碼區塊裡」— 因為節點的父子關係已經是樹的一部分。&lt;/p></description><content:encoded><![CDATA[<h2 id="為什麼會碰到這個詞">為什麼會碰到這個詞</h2>
<p>最初的問題很小：blog 文章數量成長後，每次 commit 都會收到 markdownlint 的同類警告反覆出現。有三個代表性的：</p>
<ul>
<li><code>MD034/no-bare-urls</code> — 裸 URL 散落在段落與表格。</li>
<li><code>MD024/no-duplicate-heading</code> — 平行結構章節（例如 13 個案例各自有 <code>### 弱點環節</code>）全部被判重複。</li>
<li><code>MD060/table-column-style</code> — 表格管線前後空白不一致。</li>
</ul>
<p>前兩個用現成工具 <code>--fix</code> 不一定修得乾淨，因為 <code>MD024</code> 的「重複」在我們的語境下是<strong>合法的平行結構</strong>（不同父標題下重名其實是特色），而「裸 URL 轉換」要處理表格儲存格、程式碼區塊等特殊情境，單純 regex 會誤判。</p>
<p>討論到後來關鍵字出現：<strong>要做得精確，可能要用 AST 工具，而不是 regex 工具</strong>。</p>
<p>那麼 AST 到底是什麼？跟我們熟悉的 regex / 字串處理差在哪？</p>
<h2 id="regex-工具看世界的方式字元序列">Regex 工具看世界的方式：字元序列</h2>
<p>Regex 工具處理 markdown 的方式是「逐行掃描 + pattern matching」。它看到的是字元流，沒有語法結構的概念。</p>
<p>舉個例子：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="ln">1</span><span class="cl"><span class="gu">## 【案例一】Uber 2022
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">事件中攻擊者取得 <span class="sb">`session_token`</span>，參考：
</span></span><span class="line"><span class="ln">4</span><span class="cl">https://www.uber.com/newsroom/security-update/
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">| 事件      | 來源             |
</span></span><span class="line"><span class="ln">7</span><span class="cl">| --------- | ---------------- |
</span></span><span class="line"><span class="ln">8</span><span class="cl">| Uber 2022 | https://uber.com |</span></span></code></pre></div><p>Regex 工具看到的是：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Line 1: &#34;## 【案例一】Uber 2022&#34;            ← ^#{1,6} match  → heading
</span></span><span class="line"><span class="ln">2</span><span class="cl">Line 3: &#34;事件中攻擊者取得 `session_token`...&#34;  ← 無 pattern 命中
</span></span><span class="line"><span class="ln">3</span><span class="cl">Line 4: &#34;https://www.uber.com/...&#34;            ← ^https?:// match → bare URL!
</span></span><span class="line"><span class="ln">4</span><span class="cl">Line 7: &#34;| Uber 2022 | https://uber.com |&#34;   ← ^\| match     → table row</span></span></code></pre></div><p>每一行獨立判讀，沒有上下文。Regex 工具不知道 line 4 到底是「段落的一部分」、「引用區塊裡的連結」、還是「程式碼範例」。它只看 pattern。</p>
<h2 id="ast-工具看世界的方式語法樹">AST 工具看世界的方式：語法樹</h2>
<p>AST = Abstract Syntax Tree，抽象語法樹。AST 工具先把整段 markdown 用 parser <strong>解析成結構化的樹</strong>，然後工具在樹上走訪（traverse），操作「節點」而不是「行」。</p>
<p>同一段 markdown，goldmark（Hugo 內建的 markdown parser）解析後的樹大致是：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">Document
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── Heading (level=2)
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">│   └── Text: &#34;【案例一】Uber 2022&#34;
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">├── Paragraph
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   ├── Text: &#34;事件中攻擊者取得 &#34;
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   ├── CodeSpan: &#34;session_token&#34;            ← 知道這是 inline code
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   ├── Text: &#34;，參考：&#34;
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">│   └── AutoLink: &#34;https://www.uber.com/...&#34;  ← 知道這是段落中的裸 URL
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">└── Table
</span></span><span class="line"><span class="ln">10</span><span class="cl">    ├── TableHeader: [事件, 來源]
</span></span><span class="line"><span class="ln">11</span><span class="cl">    └── TableRow
</span></span><span class="line"><span class="ln">12</span><span class="cl">        ├── TableCell: &#34;Uber 2022&#34;
</span></span><span class="line"><span class="ln">13</span><span class="cl">        └── TableCell: AutoLink &#34;https://uber.com&#34;  ← 知道這是表格儲存格中的 URL</span></span></code></pre></div><p>對同一個 URL，AST 工具能分辨「它在段落裡」還是「它在表格儲存格裡」還是「它在程式碼區塊裡」— 因為節點的父子關係已經是樹的一部分。</p>
<p>這個差異乍看像技術細節，實際影響的是能寫出什麼樣的規則。</p>
<h2 id="典型意外情境regex-會誤判的三個-case">典型意外情境：regex 會誤判的三個 case</h2>
<h3 id="程式碼區塊內的-url">程式碼區塊內的 URL</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="ln">1</span><span class="cl"><span class="gu">## 測試範例
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s">```bash
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s"></span>curl https://example.com/api  <span class="c1"># 這是程式碼範例，不該報 MD034</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s">```</span></span></span></code></pre></div><p>Regex 看到 <code>https://</code> 開頭就標記裸 URL。AST 知道這一行在 <code>FencedCodeBlock</code> 子樹內，跳過。</p>
<h3 id="front-matter-裡的--被當-heading">Front matter 裡的 <code>#</code> 被當 heading</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="ln">1</span><span class="cl">---
</span></span><span class="line"><span class="ln">2</span><span class="cl">title: &#34;Python 的 # 註解語法&#34;
</span></span><span class="line"><span class="ln">3</span><span class="cl">---
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">真正的文章內容...</span></span></code></pre></div><p>Regex 看到 <code>^#</code> 就當 heading 記一筆（title 裡面有 <code>#</code> 字元）。AST 知道 <code>---...---</code> 區塊是 YAML front matter，title 的值是字串。</p>
<h3 id="平行結構標題被誤判為重複">平行結構標題被誤判為重複</h3>
<p>在多案例教材裡：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="ln">1</span><span class="cl"><span class="gu">## 【案例一】Uber 2022
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="gu">### 弱點環節 ← 第 1 次出現
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="gu">### 攻擊路徑
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="gu">## 【案例二】Okta 2023
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">### 弱點環節 ← 第 2 次出現，regex 會直接報重複</span></span></code></pre></div><p>要用 regex 實作「不同父標題下允許重複」這種 <code>siblings_only</code> 規則，需要自己維護狀態機追蹤「目前 H2 是誰」「遇到 H3 時算哪個 H2 底下」。遇到 H4/H5 階層更複雜。</p>
<p>用 AST，父子關係已經內建在樹結構裡：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 偽代碼，實際用 goldmark walker 取代</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">h2</span> <span class="o">:=</span> <span class="k">range</span> <span class="nf">allHeadingsAtLevel</span><span class="p">(</span><span class="nx">doc</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">children</span> <span class="o">:=</span> <span class="nf">childrenOfType</span><span class="p">(</span><span class="nx">h2</span><span class="p">,</span> <span class="nx">Heading</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nf">checkDuplicates</span><span class="p">(</span><span class="nx">children</span><span class="p">)</span>  <span class="c1">// 自動只比對同一 H2 下的子標題</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>不用追蹤狀態，邏輯上直接表達。</p>
<h2 id="為什麼對我們特別重要goldmark--hugo-的-parser">為什麼對我們特別重要：goldmark = Hugo 的 parser</h2>
<p>Hugo（blog 的 static site generator）內建的 markdown parser 就是 goldmark。用 goldmark 寫 lint 有個平凡但關鍵的保證：<strong>lint 的判讀跟 Hugo render 的判讀完全一致</strong>。</p>
<p>如果用不同的 parser 寫 lint（例如 Python 的 <code>mistune</code>、JavaScript 的 <code>markdown-it</code>），很可能遇到這種尷尬：</p>
<ul>
<li>Lint 通過，但 Hugo 解析不出來，render 失敗或跑版。</li>
<li>Lint 報錯，但 Hugo 看得懂、實際沒有問題。</li>
</ul>
<p>兩套 parser 解讀差異是長尾 bug 的溫床。用同一個 parser 可以從源頭杜絕這類不一致。</p>
<h2 id="什麼時候-ast-不是必要的">什麼時候 AST 不是必要的</h2>
<p>不要為了「比較先進」就上 AST。Regex 在下列情境完全夠用：</p>
<ul>
<li>檢查每行開頭字元（<code>^#</code>、<code>^|</code>、<code>^- </code>）。</li>
<li>簡單字串替換（例如 URL 前後加 <code>&lt;&gt;</code> 包裹）。</li>
<li>不需要知道上下文的格式正規化（行尾空白、tab 轉空白）。</li>
</ul>
<p>需要 AST 才能穩定做到的是：</p>
<ul>
<li>判斷「這段文字在 code block 內嗎？」</li>
<li>判斷「這個 heading 的父 heading 是誰？」</li>
<li>追蹤跨文件的連結關係（卡片 backlink 完整性）。</li>
<li>檢查「這個 Strong 節點是不是整個段落的唯一子節點？」（MD036 粗體當標題濫用）</li>
</ul>
<p>一個實務判準：<strong>如果 rule 需要「知道這段文字處在什麼結構中」，regex 會卡住；AST 天生就有這個資訊。</strong></p>
<h2 id="我們的判斷什麼時機該升級到-ast">我們的判斷：什麼時機該升級到 AST</h2>
<p>blog 專案一開始也考慮過用 Python + regex 先頂著，等規則變複雜再升級 Go + goldmark。後來有兩件事讓我們直接選 AST：</p>
<ol>
<li><strong>MD024 siblings_only</strong> 已經是「需要上下文」的規則，regex 做得到但會寫得脆弱。</li>
<li><strong>知識卡片雙向完整性</strong>是當前在做的工作（每張卡片要被正文連到、每張卡片首段要有鄰卡連結），這類<strong>跨文件 + 段落歸屬</strong>的檢查，regex 做不出來。</li>
</ol>
<p>當需求已經在手上，延遲決策反而更貴。對我們來說，AST 不是超前部署，是<strong>現在的 blocker</strong>。</p>
<h2 id="延伸閱讀">延伸閱讀</h2>
<ul>
<li><a href="/blog/posts/blog-markdown-%E5%AF%AB%E4%BD%9C%E8%A6%8F%E7%AF%84%E8%88%87-mdtools-%E6%AA%A2%E6%9F%A5/" data-link-title="Blog Markdown 寫作規範與 mdtools 檢查" data-link-desc="本 blog 的 Markdown 排版規範權威契約。涵蓋 H1 禁用、MD024 siblings_only、反釣魚 TLD 校驗、卡片雙向完整性、front matter schema；改規則時要與 scripts/mdtools 實作同步。">Blog Markdown 寫作規範與 mdtools 檢查</a> — 所有規則的正式契約</li>
<li><a href="/blog/posts/mdtoolsgo--goldmark-%E7%9A%84-markdown-%E5%B7%A5%E5%85%B7%E9%8F%88%E8%A8%AD%E8%A8%88/" data-link-title="mdtools：Go &#43; goldmark 的 markdown 工具鏈設計" data-link-desc="mdtools 的架構決策：選 Go &#43; goldmark 的理由（與 Hugo 同源保證 lint↔render 等價）、單 binary 多子命令設計、pre-commit 整合、規則開啟紀律。">mdtools：Go + goldmark 的 markdown 工具鏈設計</a> — 如何把 AST 能力組裝成 pre-commit hook</li>
<li><a href="https://github.com/yuin/goldmark">goldmark 官方 repo</a> — Hugo 所用的 markdown parser</li>
</ul>
]]></content:encoded></item></channel></rss>