前端介面驚鴻一瞥
驚鴻一瞥

驚鴻一瞥:倉頡語言的 AST 操作

使用倉頡語言開發一個執行在服務端的倉頡程式碼工具,實現對倉頡程式的簡單語法和語義分析,同時使用前端語言設計一個簡單的圖形介面,在瀏覽器實現對程式碼工具的呼叫。

這是《倉頡語言程式設計》課程的大作業的實現。大作業要求使用倉頡語言開發一個執行在服務端的倉頡程式碼工具,實現對倉頡程式的簡單語法和語義分析,同時使用前端語言設計一個簡單的圖形介面,在瀏覽器實現對程式碼工具的呼叫。這個作業主要內容就是操作倉頡程式碼的 AST。

功能需求

以下內容為作業要求摘錄,有改動。腳註為要求原文所加。

後端功能需求

1. 功能介面

建立一個倉頡專案,在原始碼根目錄(src)下建立 CJCodeTool 類,編寫程式碼實現該類中的四個靜態函式。每一個靜態函式代表一個基礎的程式碼分析/修改功能,它們都接受一個代表單檔案程式碼文本的字串型別引數 code,經過一系列處理之後返回處理結果字串1。

除了這四種功能以外,你可以至多額外新增一種自定義功能,該功能的複雜度和實現效果會被納入評分考量。

(a) generateCodeSignature 函式

該函式的功能是生成程式碼檔案的簽名。此處的“簽名”包含“函式簽名”和“類(介面)簽名”2,具體到倉頡語言,可以理解為定義介面時使用的無實現成員函式。而類簽名則包含了類的宣告語句以及它內部成員(包括屬性,變數,函式)的簽名。

具體來說,對於變數、屬性、函式、類和介面,簽名按次序包含以下內容:

i. 修飾符(如 public)。注意:修飾符可能不存在。
ii. 關鍵字(如 func)。
iii. 識別符號,即命名。
iv. (對於類和介面)以 <: 開頭的父介面/類序列,使用 & 連線。注意:該部分可能不存在。
v. (對於函式)引數列表。該部分和原始碼保持一致即可。
vi. (對於變數、屬性和函式)以冒號開頭的返回型別。如果原始碼中未顯式宣告返回型別,則簽名中同樣不用包含。
vii. (對於類和介面)內部宣告的簽名。

資料限制:

  • 檔案中的一級宣告(即最外層的宣告)只包含函式(FuncDecl 類)、類(ClassDecl 類)和介面(InterfaceDecl 類)宣告;
  • 類和介面內部只包含變數(VarDecl 類)、屬性(PropDecl 類)和函式(FuncDecl 類)宣告;
  • 函式內部只包含變數宣告;
  • 不將形參宣告(FuncParam 類)視為宣告,也就是不必將其納入是否滿足資料限制的考慮;
  • 不存在泛型類、介面和函式宣告;
  • 不存在運算子過載函式宣告。

輸出要求:

  • 按順序輸出檔案中所有函式、類和介面的簽名(即圖2右圖的格式)。對於不符合資料限制的輸入,返回字串 'ILLEGAL INPUT'。
  • 單次縮排為四個空格,每一個簽名之間有一個空行。
  • 詞法單元之間的空格規範不做要求(但要保證語法正確性)。
(b) refactorVariable 函式

該函式的功能是通過 path 引數指定程式碼中的一個類或者函式,然後將其作用域內的某個識別符號(oldName)全部替換為另一個識別符號(newName)。對於函式,作用域是函式簽名和函式體這個整體,對於類也完全類似。

輸入規範:

  • path:形如 '函式名'、'類名'、'類名.函式名' 的格式。第一種是最外層的函式(不屬於任何類的函式)。

資料限制:

  • 不存在內層變數覆蓋、過載等影響重構作用域和路徑搜尋的特殊情況;
  • 識別符號都是合法的普通識別符號,並且不會和內建型別識別符號重名;
  • 使用 std.ast 包解析之後的語法樹只存在如下型別的節點:
    AssignExpr, BinaryExpr, Block, Body, ClassDecl, Decl, Expr, ForInExpr,
    FuncDecl, FuncParam, IfExpr, ImportContent, ImportList, LitConstExpr,
    Modifier, Node, PackageHeader, ParenExpr, Pattern, PrimitiveType,
    RangeExpr, RefExpr, RefType, ReturnExpr, Token, Tokens, TypeNode,
    UnaryExpr, VarDecl, VarPattern。

輸出要求:

  • 輸出重構後的完整程式碼。對於不符合資料限制的輸入,返回字串 'ILLEGAL INPUT'。
  • 詞法單元之間的空格和換行規範不做要求(但要保證語法正確性)。
(c) generateDocument 函式

該函式的功能是通過 path 引數指定程式碼中的一個類或者函式,然後為其生成解釋文件(以塊註釋的形式展示),用於解釋該類或者函式的功能。

輸入規範:

  • path:形如 '函式名'、'類名'、'類名.函式名' 的格式。第一種是最外層的函式(不屬於任何類的函式)。

資料限制:

  • 同上(路徑僅為三種形式,且必須存在)。

輸出要求:

  • 輸出生成文件後的完整程式碼。對於不符合資料限制的輸入,返回字串 'ILLEGAL INPUT';
  • 除了插入的文件所在的行,其他行的內容與格式應當與原始檔完全相同。
(d) foldConstant 函式

該函式的功能是將程式碼中可直接計算的常量進行摺疊3,不考慮常量傳播。

輸入規範:

  • 程式碼中的常量運算一定合法;
  • 不存在不同字面量型別之間的型別轉換;
  • 不存在計算溢位情況。

資料限制:

  • 同上,與 refactorVariable 相同;
  • 字面量只包含 Float64 和 Int64,無前字尾;
  • 運算只包含加/減/乘/除/模和取負。

輸出要求:

  • 輸出重構後的完整程式碼。對於不符合資料限制的輸入,返回字串 'ILLEGAL INPUT';
  • 常量必須被完全摺疊;
  • 型別保持不變,浮點數保留位數不做限制;
  • 空格和換行不做要求(保證語法正確性);
  • 不考慮常量傳播。

2. HTTP 伺服器

使用倉頡語言的網路程式設計介面自行設計一個 HTTP 伺服器,根據請求路徑和報文資料呼叫相應的功能函式,然後將返回值新增到響應報文中進行響應。

前端功能需求

自行設計並用前端程式語言(html,css,js 等)或前端框架(vue,react 等)編寫一個網頁,實現對後端各功能介面的呼叫,頁面操作邏輯形式不限。

一種簡單的實現思路是:網頁僅包含一個文本輸入框和一個提交按鈕,將呼叫的功能介面和除 code 以外引數寫在第一行,剩餘行輸入待處理的程式碼。點選按鈕之後,將文本框內容提交至後端進行處理,最後將返回內容以 html 段落的形式展示出來。

本大作業重點在於倉頡語言的綜合運用,故無需在前端耗費過多工作量。

實現方式限制

  • 對於後端的所有程式碼處理函式,請使用倉頡的 std.ast 標準庫進行處理。正規表示式或序列處理很難完全實現預期功能;
  • 對於 generateDocument 函式中的文件生成,必須呼叫 LLM API 來完成,建議使用 DeepSeek;
  • 對於文件的插入,由於倉頡語法樹中不存在註釋型別節點,建議通過語法樹獲取類/函式簽名所在行列數之後直接使用字串方法進行插入;
  • 請將主函式單獨放在原始碼根目錄(src)下的 main.cj 檔案中。

前端

前端部分使用 React 搭建介面。

程式碼高亮

採用了 Monaco 提供倉頡程式碼高亮。Monaco Editor 採用了一種名為 Monarch 的宣告式詞法規範。通過定義一系列正規表示式規則為詞法單元分配特定的型別。

首先建立 editor-config.js,定義 languageDef 物件,包含 keywords、typeKeywords(型別相關的關鍵字)、operators(運算子)、symbols(符號的正規表示式)和 escapes(字串中的跳脫字元規則)。最核心的部分是 tokenizer,它是一個狀態機,通過定義一系列正規表示式規則,將程式碼片段賦予特定的詞法型別。

在 CodeInput.tsx React 元件中,使用 @monaco-editor/react 包提供的 Editor 元件。在 editorBeforeMount 函式內部,呼叫 languages.register({ id: 'cangjie' }) 來註冊新語言。然後用 monaco.languages.setMonarchTokensProvider('cangjie', languageDef) 將之前定義的 Monarch 詞法規則與 'cangjie' 語言 ID 關聯起來,monaco.languages.setLanguageConfiguration('cangjie', configuration) 將語言行為配置與 'cangjie' 語言 ID 關聯。在 Editor 元件上設定 defaultLanguage="cangjie" 即可。

HttpServer

主要踩的坑

std.ast.cangjieLex()、parseProgram 甚至部分結構體的 toTokens() 都不是執行緒安全的,需要呼叫 CJCodeTool 類中的方法時 spawn:

let output = spawn { CJCodeTool.generateDocument(...) }.get()

類似 Rust,倉頡支援尾語句作為塊的返回值,所以寫起來不算麻煩。

CORS

現代瀏覽器在進行網路請求時,會遵循同源策略(Same-Origin Policy),不允許跨域請求,即對於 協議域名端口 這一三元組,如果兩個請求的這一三元組不同,則不允許跨域請求。

Sticker of a cat, inquisitive
前端之貓

這是一段經典的前端八股。

因此,如果需要跨域請求,最好的方法是在伺服器端設定 CORS 頭。

func sendRes(httpContext: HttpContext, output: String) {
    let headers = HttpHeaders()
    // cors
    headers.add("Access-Control-Allow-Origin", "*")
    headers.add("Access-Control-Allow-Methods", "POST, GET")
    headers.add("Access-Control-Allow-Headers", "Content-Type")
    // 設定 content-type
    headers.add("Content-Type", "text/plain")
    httpContext.responseBuilder.addHeaders(headers).body(output)
}

當然,還有下策:

我草 CORS 怎麼這麼壞啊
我草 CORS 怎麼這麼壞啊

(a) generateCodeSignature 函式

該函式的功能是生成程式碼檔案的簽名。此處的“簽名”包含“函式簽名”和“類(介面)簽名”,具體到倉頡語言,可以理解為定義介面時使用的無實現成員函式。而類簽名則包含了類的宣告語句以及它內部成員(包括屬性,變數,函式)的籤 名。

實現步驟

  1. 使用 cangjieLex(code) 將輸入的程式碼字串詞法分析為 Tokens。
  2. 使用 parseProgram(tokens) 將 Tokens 解析為抽象語法樹(AST),根節點為 Program 型別。
  3. 遍歷 program.decls 中的頂層宣告。通過模式匹配(match 表示式)區分 FuncDecl(函式宣告)、ClassDecl(類宣告)和 InterfaceDecl(介面宣告)。
  4. 對於 FuncDecl:直接提取其函式體起始花括號之前的所有 token。
  5. 對於 ClassDecl 和 InterfaceDecl:提取其修飾符、class / interface 關鍵字、名稱、父介面/類序列(如果存在)。然後遞迴處理其內部成員(VarDecl、PropDecl、FuncDecl),提取這些成員的簽名,並進行適當的縮排。類/介面的實現體被省略,僅保留簽名的巢狀結構。
  6. 實現中,通過獲取宣告節點的 toTokens(),然後有選擇地拼接或替換部分 Token(例如,將類或函式體的 {...} 替換為僅包含成員簽名的 {...})來構造最終的簽名字串。
  7. 輸出時,確保每個簽名之間有空行,縮排使用四個空格。這部分是根據序列化後的實現行為手動加入空行。
  8. 若輸入不符合資料限制(如頂層宣告包含非函式、類、介面,或內部宣告不符合要求),則返回 “ILLEGAL INPUT”。

模式匹配型別的語法

tool.cj
class CJCodeTool {
    static func generateCodeSignature(code: String): String {
        let tokens = cangjieLex(code)
        // 任何一個倉頡原始碼檔案都可以被解析為一個 Program 型別。
        let program = parseProgram(tokens)
        let sig = quote()
        for (decl in program.decls) {
            let result = match (decl) {
                case d: FuncDecl => genFuncSig(d)
                case d: ClassDecl => genClassSig(d)
                case d: InterfaceDecl => genInterfaceSig(d)
                case _ => throw Exception("Unexpected Decl")
            } |> sig.append
        }
        return trimTokensEnd(sig).toString()
    }

直接截斷

對於 genBlockSig 和 genBodySig 函式,它們分別用於生成函式塊和類/介面體的簽名。genBlockSig 直接返回空的 Tokens,因為函式簽名不需要包含函式體的實現細節。而 genBodySig 則需要遞迴處理類/介面體內的成員宣告,保留其簽名結構。

public func genFuncSig(funcDecl: FuncDecl): Tokens {
    let tokens = funcDecl.toTokens()
    let blockSig = genBlockSig(funcDecl.block)
    return genSig(tokens, funcDecl.block.lBrace, funcDecl.block.rBrace, blockSig)
}
 
public func genClassSig(classDecl: ClassDecl): Tokens {
    let tokens = classDecl.toTokens()
    let bodySig = genBodySig(classDecl.body)
    return genSig(tokens, classDecl.body.lBrace, classDecl.body.rBrace, bodySig)
}
 
public func genInterfaceSig(interfaceDecl: InterfaceDecl): Tokens {
    let tokens = interfaceDecl.toTokens()
    let bodySig = genBodySig(interfaceDecl.body)
    return genSig(tokens, interfaceDecl.body.lBrace, interfaceDecl.body.rBrace, bodySig)
}

相等的條件:完全一致

倉頡中 Token 判斷相等的條件是位置完全一致,所以不會出現僅僅是重複的 Token 就被認為是相等的情況。藉助這個特性,我們可以通過 lParen 等特殊 token 來擷取之前的部分。

func genSig(tokens: Tokens, lParen: Token, rParen: Token, innerSig: Tokens): Tokens {
    let sig = quote()
    var isInBlock = false
    for ((index, token) in enumerate(tokens)) {
        if (token == lParen) {
            sig.append(token)
            isInBlock = true
            sig.append(genNewLines(1))
            sig.append(innerSig)
        } else if (token == rParen) {
            sig.append(genNewLines(1))
            sig.append(token)
            isInBlock = false
        } else if (!isInBlock) {
            sig.append(token)
        }
    }
    sig.append(genNewLines(1))
}

測試用例

輸入
interface I1 {
    mut prop size: Int64
}
 
class C <: I1 {
    private var mySize = 0
 
    public mut prop size: Int64 {
        get() {
            mySize
        }
        set(value) {
            mySize = value
        }
    }
 
    public func getSize() {
        mySize
    }
}
 
public func testC() {
    let a: I1 = C()
    a.size = 5
    if (a.size == 5) {
        println("Do nothing")
    } else {
        println("Do nothing")
    }
    println(a.size)
}
輸出
interface I1 {
    mut prop size: Int64
}
 
class C <: I1 {
    private var mySize
    
    public mut prop size: Int64
    
    public func getSize()
}
 
public func testC() {
    
    
}

(b) refactorVariable 函式

該函式的功能是通過path 引數指定程式碼中的一個類或者函式,然後將其作用域內的某個識別符號(oldName)全部替換為另一個識別符號(newName)。對於函式, 作用域是函式簽名和函式體這個整體,對於類也完全類似。

實現步驟

  1. 將輸入程式碼 code 解析為 AST。
  2. 解析 path 引數(如 “函式名”, “類名”, “類名.函式名”)以定位目標作用域。
  3. 遍歷 program.decls 找到路徑的第一部分匹配的頂層宣告(函式或類)。
  4. 如果路徑包含 ”.” (如 “類名.函式名”),則在找到的類宣告內部繼續查詢匹配的函式名。
  5. 一旦定位到目標作用域(FuncDecl 或 ClassDecl 節點),將其轉換為 Tokens。
  6. 遍歷這些 Tokens,當遇到 TokenKind.IDENTIFIER 且其 value 等於 oldName 時,將其替換為一個新的 Token,其 kind 仍為 TokenKind.IDENTIFIER,但 value 為 newName。
  7. 將修改後的 Tokens 重新組合成字串。
  8. 如果 path 在原始碼中不存在或不符合資料限制,返回 “ILLEGAL INPUT”。

主要思想

由於作業要求中明確寫了

詞法單元之間的空格和換行規範不做要求(但要保證語法正確性),

因此可以直接修改語法樹,然後再將語法樹轉換為詞法單元。這樣雖然會丟掉原來的空格和換行,但是比直接替換 tokens 要方便得多。

static func refactorVariable(code: String, path: String, oldName: String, newName: String): String {
    let tokens = cangjieLex(code)
    let program = parseProgram(tokens)
    let pathParts = path.split(".")
    for (i in 0..program.decls.size) {
        let decl = program.decls[i]
        if (decl.identifier.toTokens().toString() == pathParts[0]) {
            let replaced = refactorByPath(decl, path, oldName, newName)
            let newDecl = parseDecl(replaced)
            program.decls[i] = newDecl // 詞法單元之間的空格和換行規範不做要求(但要保證語法正確性)
            return program.toTokens().toString()
        }
    }
    throw Exception("Variable not found")
}

其實應直接修改 AST 節點中的識別符號,然後呼叫整個 Program 節點的 toTokens().toString() 生成新程式碼,但倉頡的 AST 各個節點類的欄位都是不可變的。因此本實現在定位到具體節點後,對其 toTokens() 的結果進行操作,然後將這部分修改後的 tokens 插入回整體程式碼結構中。

一些假設

由於這些假設,我們可以直接遍歷 program.decls 找到目標宣告,然後直接對其 toTokens() 的結果進行替換:

  1. 路徑唯一且合法;
  2. 作用域明確且無變數遮蔽,不考慮形參與內部變數衝突;
  3. 不會過載函式;
  4. 僅通過詞法替換實現重構;
  5. 被替換的 oldName 出現在語法樹中 toTokens() 的結果中。
public func refactorByPath(decl: Decl, path: String, oldName: String, newName: String): Tokens {
    var tokens = decl.toTokens()
    if (let Some(index) <- path.indexOf(".")) {
        let parts = path.split(".")
        let funcName = parts[1]
        let replaced = quote()
        let classDecl = (decl as ClassDecl).getOrThrow()
        let innerDecls = classDecl.body.decls
        for (i in 0..innerDecls.size) {
            let innerDecl = innerDecls[i]
            if (innerDecl.identifier.toTokens().toString() == funcName) {
                let replaced = refactorByPath(innerDecl, funcName, oldName, newName)
                let newDecl = parseDecl(replaced)
                classDecl.body.decls[i] = newDecl
                tokens = classDecl.toTokens()
                return tokens
            }
        }
        throw Exception("No method found")
    } else {
        let replaced = quote()
        for (token in tokens) {
            if (token.kind == TokenKind.IDENTIFIER && token.value == oldName) {
                replaced.append(Token(TokenKind.IDENTIFIER, newName))
            } else {
                replaced.append(token)
            }
        }
        replaced
    }
}

測試用例

重構引數:testC,a,b

程式碼輸入:

public func testC() {
    let a: I1 = C()
    a.size = 5
    if (a.size == 5) {
        println("Do nothing")
    } else {
        println("Do nothing")
    }
    println(a.size)
}

輸出:

public func testC() {
    let b: I1 = C()
    b.size = 5
    if(b.size == 5) {
        println("Do nothing")
    }
    else {
        println("Do nothing")
    }
    println(b.size)
}

測試用例:DocumentReq.deserialize,dms,dataModels

class DocumentReq <: Serializable<DocumentReq> {
    var code: String = ""
    var path: String = ""
 
    public DocumentReq(code: String, path: String) {
        this.code = code
        this.path = path
    }
 
    public func serialize(): DataModel {
        return DataModelStruct().add(field<String>("code", code)).add(field<String>("path", path))
    }
 
    public static func deserialize(dm: DataModel): DocumentReq {
        var dms = match (dm) {
            case data: DataModelStruct => data
            case _ => throw Exception("this data is not DataModelStruct for DocumentReq")
        }
        let code = String.deserialize(dms.get("code"))
        let path = String.deserialize(dms.get("path"))
        DocumentReq(code, path)
    }
}
class DocumentReq <: Serializable < DocumentReq > {
    var code: String = ""
    var path: String = ""
    public DocumentReq(code: String, path: String) {
        this.code = code
        this.path = path
    }
    public func serialize(): DataModel {
        return DataModelStruct().add(field < String >("code", code)).add(field < String >("path", path))
    }
    public static func deserialize(dm: DataModel): DocumentReq {
        var dataModels = match(dm) {
            case data: DataModelStruct => data
            case _ => throw Exception("this data is not DataModelStruct for DocumentReq")
        }
        let code = String.deserialize(dataModels.get("code"))
        let path = String.deserialize(dataModels.get("path"))
        DocumentReq(code, path)
    }
}

(c) generateDocument 函式

該函式的功能是通過path 引數指定程式碼中的一個類或者函式,然後為其生成解釋文件(以塊註釋的形式展示),用於解釋該類或者函式的功能。

實現步驟

  1. 初始化 LLM 類,配置了 API 地址、從 .key.txt 檔案讀取的金鑰、使用的模型名稱(如 Qwen/Qwen3-8B)以及啟用記憶功能;
  2. 為 LLM 設定了一個系統級指令;
  3. 構建一個傳送給 LLM 的具體提示詞(prompt),包含指令以及使用者提供的完整程式碼;
  4. 將此提示傳送給 LLM 進行處理,並獲取 LLM 返回的結果。由於採用 8B 模型,所以沒有使用流式對話。

測試用例

輸入
package final
 
import std.io.StringReader
import std.time.Duration
import encoding.json.*
import net.http.*
import net.tls.*
 
enum Role <: ToString {
    I | AI | System
 
    public func toString() {
        match (this) {
            case I => 'user'
            case AI => 'assistant'
            case System => 'system'
        }
    }
}
 
class LLM {
    let client: Client
    let history = StringBuilder()
    public LLM(let url!: String, let key!: String, let model!: String, let memory!: Bool = false) {
        var config = TlsClientConfig()
        config.verifyMode = TrustAll
        client = ClientBuilder().tlsConfig(config).readTimeout(Duration.Max).build()
    }
 
    func encode(role: Role, content: String) {
        '{"role":"${role}","content":${JsonString(content)}}'
    }
 
    func send(input: String, stream!: Bool = false) {
        let message = encode(I, input)
        let content = '{"model":"${model}","messages":[${history}${message}],"stream":${stream}}'
        if (memory) {
            history.append(message)
        }
        let request = HttpRequestBuilder()
            .url(url)
            .header('Authorization', 'Bearer ${key}')
            .header('Content-Type', 'application/json')
            .header(
                'Accept',
                if (stream) {
                    'text/event-stream'
                } else {
                    'application/json'
                }
            )
            .body(content)
            .post()
            .build()
        client.send(request)
    }
 
    func parse(text: String, stream!: Bool = false) {
        println(text)
        let json = JsonValue.fromStr(text).asObject()
        let choices = json.getFields()['choices'].asArray()
        // 流式和非流式情況下,這個欄位名稱不同
        let key = if (stream) {
            'delta'
        } else {
            'message'
        }
        let message = choices[0].asObject().getFields()[key].asObject()
        let content = message.getFields()['content'].asString().getValue()
        return content
    }
 
    public func chats(input: String, task!: (String) -> Unit = {o => print(o)}) {
        const INDEX = 6
        let response = send(input, stream: true)
        let output = StringBuilder()
        let buffer = Array<Byte>(1024 * 8, item: 0)
        var length = response.body.read(buffer)
        while (length != 0) {
            let text = String.fromUtf8(buffer[..length])
            for (line in text.split('\n', removeEmpty: true)) {
                if (line.size > INDEX && line[INDEX] == b'{') {
                    let json = line[INDEX..line.size]
                    let slice = parse(json, stream: true)
                    if (memory) {
                        output.append(slice)
                    }
                    task(slice)
                }
            }
            length = response.body.read(buffer)
        }
        if (memory) {
            history.append(',${encode(AI, output.toString())},')
        }
    }
 
    public func chat(input: String) {
        let response = send(input)
        let output = StringReader(response.body).readToEnd() |> parse
        if (memory) {
            history.append(',${encode(AI, output)},')
        }
        return output
    }
 
    public func preset(memory: Array<(Role, String)>) {
        history.reset()
        for ((role, message) in memory) {
            history.append(encode(role, message) + ',')
        }
    }
 
    public func reset() {
        history.reset()
    }
}
輸出
public func chats(input: String, task!: (String) -> Unit = {o => print(o)}) {
    /**
     * 傳送流式聊天請求並即時處理響應內容
     * 
     * @param input 使用者輸入內容
     * @param task 處理每一塊響應內容的回撥函式
     *              該函式接收String引數,返回Unit
     * @return 無返回值
     * 
     * 注意:
     * 1. 該方法使用stream模式傳送請求
     * 2. 通過task回撥即時接收並處理響應內容
     * 3. 若啟用memory,會將AI回答內容追加到history
     * 4. 每次呼叫會重置當前聊天會話的流式狀態
     */
    const INDEX = 6
    let response = send(input, stream: true)
    let output = StringBuilder()
    let buffer = Array<Byte>(1024 * 8, item: 0)
    var length = response.body.read(buffer)
    while (length != 0) {
        let text = String.fromUtf8(buffer[..length])
        for (line in text.split('\n', removeEmpty: true)) {
            if (line.size > INDEX && line[INDEX] == b'{') {
                let json = line[INDEX..line.size]
                let slice = parse(json, stream: true)
                if (memory) {
                    output.append(slice)
                }
                task(slice)
            }
        }
        length = response.body.read(buffer)
    }
    if (memory) {
        history.append(',${encode(AI, output.toString())},')
    }
}

(d) foldConstant 函式

該函式的功能是將程式碼中可直接計算的常量進行摺疊,不考慮常量傳播。例如,如果程式碼中存在一句 let a = 3 + 2,則摺疊之後這句程式碼就變為 let a = 5。


使用 visitor 訪問節點

倉頡的 Visitor 類體現了過載的方便之處:只需要實現想要處理的型別的函式實現即可。

class MyVisitor <: Visitor {
    public var result = ArrayList<BinaryExpr>()
 
    private func isConstExpr(expr: BinaryExpr): Bool {
        let isLeftConst = match (expr.leftExpr) {
            case left: LitConstExpr => true
            case left: BinaryExpr => isConstExpr(left)
            case left: ParenExpr => isConstExpr(left)
            case left: UnaryExpr => isConstExpr(left)
            case _ => false
        }
        if (!isLeftConst) { // early return
            return false
        }
        let isRightConst = match (expr.rightExpr) {
            case right: LitConstExpr => true
            case right: BinaryExpr => isConstExpr(right)
            case right: ParenExpr => isConstExpr(right)
            case right: UnaryExpr => isConstExpr(right)
            case _ => false
        }
        isRightConst
    }
 
    private func isConstExpr(expr: UnaryExpr): Bool {
        match (expr.op) {
            ...
        }
    }
 
    private func isConstExpr(expr: ParenExpr): Bool {
        match (expr.parenthesizedExpr) {
            case inner: BinaryExpr => isConstExpr(inner)
            case inner: LitConstExpr => true
            case inner: ParenExpr => isConstExpr(inner)
            case inner: UnaryExpr => isConstExpr(inner)
            case _ => false
        }
    }
 
    public override func visit(expr: BinaryExpr) {
        if (isConstExpr(expr)) {
            result.append(expr)
            breakTraverse() // 不會繼續遍歷子節點
        }
        return
    }
}
 
...
let program = parseProgram(tokens)
program.traverse(visitor)
  • 解析為 AST(抽象語法樹)後,使用 visitor 訪問節點
  • 定位所有 BinaryExpr,確保只處理結構合法且可摺疊的目標
  • 終止的條件是 BinaryExpr 遞迴到所有子節點沒有變數等識別符號
  • 倉頡不支援邊訪問邊替換,將結果收集起來

處理不同數值型別

enum ConstLit <: ToString {
    Float(Float64) | Int(BigInt)
 
    public func toToken(): Token { ... }
    ...
}
...
    match ((left, right)) {
      case (ConstLit.Float, ConstLit.Float) => ...
      case (ConstLit.Int, ConstLit.Int) => ...
    }
  • 整數和浮點數分別使用 BigInt 和 Float64 型別,避免精度丟失或型別錯誤。
  • 用 ConstLit 列舉包裹值型別,統一抽象、便於擴充套件。

遞迴計算收集的常量表示式

private func calculateLit(left: ConstLit, op: Token, right: ConstLit): ConstLit {
    match ((left, right)) {
        case (ConstLit.Float(left), ConstLit.Float(right)) => ConstLit.Float(calculateFloat(left, op, right))
        case (ConstLit.Int(left), ConstLit.Int(right)) => ConstLit.Int(calculateInteger(left, op, right))
        case _ => throw Exception("Unexpected ConstLit")
    }
}
 
private func calculateExpr(expr: LitConstExpr): ConstLit {
    let token = expr.toTokens()[0]
    match (token.kind) {
        case TokenKind.FLOAT_LITERAL => ConstLit.Float(Float64.parse(token.value))
        case TokenKind.INTEGER_LITERAL => ConstLit.Int(BigInt(token.value))
        case _ => throw Exception("Unexpected ConstLit")
    }
}
 
private func calculateExpr(expr: Expr): ConstLit {
    match (expr) {
        case expr: LitConstExpr => calculateExpr(expr)
        case expr: BinaryExpr => calculateExpr(expr)
        case _ => throw Exception("Unexpected Expr")
    }
}
 
private func calculateExpr(expr: BinaryExpr): ConstLit {
    let left = calculateExpr(expr.leftExpr)
    let right = calculateExpr(expr.rightExpr)
    calculateLit(left, expr.op, right)
}

在原始 token 流中直接定位並替換

Tokens 類不是 Array,缺少很多功能,不知道怎麼想的。

let startIndex = findTokenIndex(tokens, left)
let endIndex = findTokenIndex(tokens, right)
newTokens = replaceTokensByRange(newTokens, startIndex..endIndex, foldedToken)

通過逆序處理避免 token 替換錯位:

public func comparePos(a: Position, b: Position): Ordering {
    match (a.line.compare(b.line)) {
        case Ordering.EQ => a.column.compare(b.column)
        case notEq => notEq
    }
}
...
exprs.sortBy(stable: false) {
  b: BinaryExpr, a: BinaryExpr => comparePos(a.beginPos, b.beginPos)
}

測試用例

輸入
interface I1 {
    mut prop size: Int64
}
 
class C <: I1 {
    private var mySize = -10 * (3 + 2 * 4) + (-2) * 2 // -114
    private var mySizeBin = 2 * 4
    private var floatSize: Float32 = 3.0 + 4.5 / 3.3
    private var mySize2 = (32 / 4) % 5
 
    public mut prop size: Int64 {
        get() {
            mySize + mySize2 + (233 -666)
        }
        set(value) {
            mySize -= value
        }
    }
 
    public func getSize() {
        mySize
    }
}
 
public func testC() {
    let a: I1 = C()
    a.size = 12-3*5/(5+6%4)
    if (a.size == 23-5) {
        println("Do nothing")
    } else {
        println("Do nothing")
    }
    println(a.size)
}
輸出
interface I1 {
    mut prop size: Int64
}
 
class C <: I1 {
    private var mySize = -114 // -114
    private var mySizeBin = 8
    private var floatSize: Float32 = 4.363636
    private var mySize2 = 3
    
    public mut prop size: Int64 {
        get() {
            mySize + mySize2 +(-433)
        }
        set(value) {
            mySize -= value
        }
    }
    
    public func getSize() {
        mySize
    }
}
 
public func testC() {
    let a: I1 = C()
    a.size = 10
    if(a.size == 18) {
        println("Do nothing")
    } else {
        println("Do nothing")
    }
    println(a.size)
}

(e) generateJsonAst 自定義功能

將倉頡程式碼解析後的 AST 轉換為 JSON 格式的字串。此功能有助於理解 AST 結構,也可用於與其他 AST 視覺化工具(如 astexplorer.net 的自定義解析器)整合。

實現思路

  1. 使用 cangjieLex(code) 和 parseProgram(tokens) 得到 AST (Program 型別)。
  2. 設計一系列遞迴的 toJson 轉換函式,每個函式對應一種 AST 節點型別。
  3. 每個 toJson 函式會建立一個 JsonObject。將節點型別和節點的屬性作為鍵值對新增到 JsonObject 中。
  4. 如果節點的屬性本身也是 AST 節點(如函式體 Block 是一個節點,它包含一系列 Stmt 節點組成的陣列),則遞迴呼叫相應的 toJson 函式處理這些子節點,並將結果(JsonObject | JsonArray)作為父物件的屬性值。
  5. 基本型別的屬性直接作為 JSON 值。Token 型別的資訊,如位置(beginPos, endPos),也一併轉換為 JSON 物件。
  6. program.decls 是一個 ArrayList<Decl>,會被轉換成一個 JsonArray,陣列中的每個元素是對應 Decl 節點轉換後的 JsonObject。
  7. 最終,整個 Program AST 被轉換為一個頂層的 JsonObject 或 JsonArray,然後序列化為 JSON。

為所有 AST 節點型別編寫轉換函式會導致程式碼量較大,編譯時間也相應增加。即使用了宏,程式碼量也達到了驚人的上千行。這堆宏具體也是在網頁文件中 document.querySelectorAll() 所有標題後複製出來,批次編輯了後寫了個 Python 生成的😄。

Sticker of a cat, rolling
前端之貓

程式碼生成也是一種超程式設計。

為什麼這麼想不開呢?這是因為文件裡說:

在倉頡程式語言中,所有的泛型都是不型變的。這意味著如果 A 是 B 的子型別,ClassName<A> 和 ClassName<B>之間沒有子型別關係。我們禁止這樣的行為以保證執行時的安全。

因此,我們無法通過反射判斷 ArrayList<Decl> 中的 Decl 是 ClassDecl 還是 FuncDecl 之類。雖然我們可以寫出

func toJson(node: Node): JsonObject {
    ...
    let typeinfo = TypeInfo.of(node)
    for (property in typeinfo.instanceProperties) {
        let name = property.name
        let valueInfo = typeinfo.getInstanceProperty(name)
        if (valueInfo.typeInfo.qualifiedName.startsWith("std.collection.ArrayList")) {
            obj.put(name, castArray(node, valueInfo))
        } else {
            let value = valueInfo.getValue(node)
            if (let Some(value) <- value as Node) {
                obj.put(name, toJson(value))
            } else {
                println("No value for ${name} ${valueInfo.typeInfo.qualifiedName}")
            }
        }
    }
    obj
}
 
func toJson<T>(value: ArrayList<T>): JsonArray where T <: Node {
    let arr = JsonArray()Add commentMore actions
    for (elem in value) {Add commentMore actions
        arr.add(toJson(elem))
    }
    arr
}

但 castArray 還是都需要判斷一遍:

func castArray(node: Node, valueInfo: InstancePropertyInfo): JsonArray {
    if (let Some(value) <- valueInfo.getValue(node) as ArrayList<ast.Annotation>) {
        toJson<ast.Annotation>(value)
    } else if (let Some(value) <- valueInfo.getValue(node) as ArrayList<Argument>) {
        toJson<Argument>(value)
    } else if (let Some(value) <- valueInfo.getValue(node) as ArrayList<ArrayLiteral>) {
        toJson<ArrayLiteral>(value)
    } else if (let Some(value) <- valueInfo.getValue(node) as ArrayList<AsExpr>) {
        toJson<AsExpr>(value)
    } else ...

編譯時間過長,完全不可用。所以最後還是手動對允許繼承的類進行型別判斷,如下:

func toJson(decls: ArrayList<Decl>): JsonArray {
    let arr = JsonArray()
    for (decl in decls) {
        let res = match (decl) {
            case d: ClassDecl => toJson(d)
            case d: EnumDecl => toJson(d)
            case d: ExtendDecl => toJson(d)
            case d: FuncDecl => toJson(d)
            case d: FuncParam => toJson(d)
            case d: InterfaceDecl => toJson(d)
            case d: MacroExpandDecl => toJson(d)
            case d: MacroDecl => toJson(d)
            case d: MainDecl => toJson(d)
            case d: PrimaryCtorDecl => toJson(d)
            case d: PropDecl => toJson(d)
            case d: StructDecl => toJson(d)
            case d: VarDecl => toJson(d)
            case d: TypeAliasDecl => toJson(d)
            case d: Decl => return arr
        }
        arr.add(res)
    }
    arr
}

生成程式碼

目前倉頡的增量編譯非常侷限,導致整個專案的編譯時間增加了 800%。可以通過放到單獨的包裡來解決一部分效能問題。

quote

倉頡的 quote 是一個用於生成 Tokens 的特殊語法,其中可以直接寫表示式,然後會被轉換為 Tokens。比如可以這樣生成空行:

let nl = quote(
)

嵌入變數使用 $(變數名) 的形式。但是,對於在一個變數名外加雙引號作為字串,就比較麻煩了:

quote(obj.put($("tokens"), toJson(token.$(tokens))))

這樣只會生成 obj.put("tokens", toJson(token.變數名)),而不是 obj.put("變數名", toJson(token.$("變數名")))。因此,我採取的做法是

public macro put(tokens: Tokens): Tokens {
    let wrapped = Token(TokenKind.STRING_LITERAL, tokens.toString())
    let res = quote(
        obj.put($(wrapped), toJson(token.$(tokens)))
    )
    res
}

astexplorer

astexplorer 是一個用於視覺化 AST 的工具,支援多種語言。我們將倉頡的 AST 轉換為 JSON 後,在 astexplorer 中可以方便地檢視。滑鼠可懸浮在對應位置跳轉到語法樹,反之亦然。

astexplorer
astexplorer

注意 astexplorer 專案需要使用 Node 16。

volta pin node@16 # 使用 volta 管理 node 版本
cd astexplorer
yarn install
cd website
yarn start

適配 astexplorer 需要新增新的語言 parser。

  • async loadParser{:js} 函式在本實現中僅呼叫回撥,因為倉頡目前沒法像其他語言一樣編譯到 WASM,我們在 async parse 中負責接收 Cangjie 程式碼,計算每行起始偏移量,並通過 fetch 向本地服務 /ast 傳送 POST 請求以獲取 AST。
  • getNodeName 函式簡單地使用上述我們在輸出中新增的 _type 屬性作為其在 AST 樹檢視中的名稱。
  • 倉頡 Token 預設給出 line 和 column,輔助函式 getOffset 將 1-based 的行列號轉換為 0-based 的字元偏移量。
  • nodeToRange 是實現 AST 和原始碼之間對映的關鍵,它根據節點是否具有 beginPos 和 endPos,或者 pos 和 value 屬性來計算節點在原始碼中的起始和結束偏移量。對於後者,實現採用 startOffset + node.value.length 的方式計算結束偏移量。
  • opensByDefault 定義了 AST 一開始是否展開。這裡我們讓有 _type 屬性的節點或陣列預設展開,避免顯示過多的 Token。
website/src/parsers/cangjie/cj.js
import defaultParserInterface from '../utils/defaultParserInterface';
export default {
  ...defaultParserInterface,
  id: 'cj',
  displayName: ID,
  version: '1.13.4',
  homepage: 'https://cangjie-lang.cn/',
  _ignoredProperties: new Set(['_type']),
  locationProps: new Set(['pos', 'beginPos', 'endPos']),
 
  async loadParser(callback) {
    callback({
      parseFile: (code) => {},
    });
  },
 
  async parse(parser, code) {
    this.lineOffsets = [];
    let index = 0;
    do {
      this.lineOffsets.push(index);
    } while ((index = code.indexOf('\n', index) + 1));
    console.info('lineOffsets', this.lineOffsets);
    let resp = await fetch('http://localhost:8001/ast', {
      method: 'POST',
      body: code,
    });
    return resp;
  },
 
  getNodeName(node) {
    return node._type;
  },
 
  getOffset({ line, column }) {
    return this.lineOffsets[line - 1] + column - 1;
  },
 
  nodeToRange(node) {
    if (node.beginPos && node.endPos) {
      const start = node.beginPos;
      const end = node.endPos;
      const startOffset = this.getOffset(start);
      const endOffset = this.getOffset(end);
      return [startOffset, endOffset];
    }
    if (node.pos && node.value) {
      const start = node.pos;
      const startOffset = this.getOffset(start);
      const endOffset = startOffset + node.value.length;
      console.log('nodeToRange', node, startOffset, endOffset);
      if (Number.isNaN(startOffset)) {
        return;
      }
      return [startOffset, endOffset];
    }
  },
 
  opensByDefault(node, key) {
    return node._type || Array.isArray(node);
  },
};

修改後的程式碼見 OverflowCat/astexplorer。

---

  1. 程式碼文本可在北航雲盤獲取 ↩

  2. 函式簽名的定義可以參考維基百科 ↩

  3. 常量摺疊的定義可以參考維基百科 ↩

評論

評論將在稽核後顯示,閣下可以在本部落格的 GitHub 倉庫的 拉取請求列表 中檢視。提交成功後會自動跳轉。

本站不支持 Dark Reader 的暗色模式,请对本站关闭后再访问,亮色模式的对比度、亮度等选项不受影响。部分页面右上角提供暗色模式切换按钮,如果你没看到,说明你的浏览器尚不支持此特性。本提示不依赖于 JavaScript,你可自行查找其他用户在本站发表的关于如何关闭此提示的评论。