前端介面

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

日期:
分類: 驚鴻一瞥 5
標籤: 倉頡程式語言 3

這是《倉頡語言程式設計》課程的大作業的實現。大作業要求使用倉頡語言開發一個執行在服務端的倉頡程式碼工具,實現對倉頡程式的簡單語法和語義分析,同時使用前端語言設計一個簡單的圖形介面,在瀏覽器實現對程式碼工具的呼叫。這個作業主要內容就是操作倉頡程式碼的 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 相同;
  • 字面量只包含 Float64Int64,無前字尾;
  • 運算只包含加/減/乘/除/模和取負。

輸出要求:

  • 輸出重構後的完整程式碼。對於不符合資料限制的輸入,返回字串 '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 物件,包含 keywordstypeKeywords(型別相關的關鍵字)、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. 對於 ClassDeclInterfaceDecl:提取其修飾符、class / interface 關鍵字、名稱、父介面/類序列(如果存在)。然後遞迴處理其內部成員(VarDeclPropDeclFuncDecl),提取這些成員的簽名,並進行適當的縮排。類/介面的實現體被省略,僅保留簽名的巢狀結構。
  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()
    }

直接截斷

對於 genBlockSiggenBodySig 函式,它們分別用於生成函式塊和類/介面體的簽名。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. 一旦定位到目標作用域(FuncDeclClassDecl 節點),將其轉換為 Tokens
  6. 遍歷這些 Tokens,當遇到 TokenKind.IDENTIFIER 且其 value 等於 oldName 時,將其替換為一個新的 Token,其 kind 仍為 TokenKind.IDENTIFIER,但 valuenewName
  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) => ...
    }
  • 整數和浮點數分別使用 BigIntFloat64 型別,避免精度丟失或型別錯誤。
  • 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 被轉換為一個頂層的 JsonObjectJsonArray,然後序列化為 JSON。

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

Sticker of a cat, rolling
前端之貓

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

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

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

因此,我們無法透過反射判斷 ArrayList<Decl> 中的 DeclClassDecl 還是 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 預設給出 linecolumn,輔助函式 getOffset 將 1-based 的行列號轉換為 0-based 的字元偏移量。
  • nodeToRange 是實現 AST 和原始碼之間對映的關鍵,它根據節點是否具有 beginPosendPos,或者 posvalue 屬性來計算節點在原始碼中的起始和結束偏移量。對於後者,實現採用 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,你可自行查找其他用户在本站发表的关于如何关闭此提示的评论。