這是《倉頡語言程式設計》課程的大作業的實現。大作業要求使用倉頡語言開發一個執行在服務端的倉頡程式碼工具,實現對倉頡程式的簡單語法和語義分析,同時使用前端語言設計一個簡單的圖形介面,在瀏覽器實現對程式碼工具的呼叫。這個作業主要內容就是操作倉頡程式碼的 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),不允許跨域請求,即對於

這是一段經典的前端八股。
因此,如果需要跨域請求,最好的方法是在伺服器端設定 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)
}
當然,還有下策:

(a) generateCodeSignature
函式
該函式的功能是生成程式碼檔案的簽名。此處的“簽名”包含“函式簽名”和“類(介面)簽名”,具體到倉頡語言,可以理解為定義介面時使用的無實現成員函式。而類簽名則包含了類的宣告語句以及它內部成員(包括屬性,變數,函式)的籤 名。
實現步驟
- 使用
cangjieLex(code)
將輸入的程式碼字串詞法分析為Tokens
。 - 使用
parseProgram(tokens)
將Tokens
解析為抽象語法樹(AST),根節點為Program
型別。 - 遍歷
program.decls
中的頂層宣告。透過模式匹配(match
表示式)區分FuncDecl
(函式宣告)、ClassDecl
(類宣告)和InterfaceDecl
(介面宣告)。 - 對於
FuncDecl
:直接提取其函式體起始花括號之前的所有 token。 - 對於
ClassDecl
和InterfaceDecl
:提取其修飾符、class / interface
關鍵字、名稱、父介面/類序列(如果存在)。然後遞迴處理其內部成員(VarDecl
、PropDecl
、FuncDecl
),提取這些成員的簽名,並進行適當的縮排。類/介面的實現體被省略,僅保留簽名的巢狀結構。 - 實現中,透過獲取宣告節點的
toTokens()
,然後有選擇地拼接或替換部分 Token(例如,將類或函式體的{...}
替換為僅包含成員簽名的{...}
)來構造最終的簽名字串。 - 輸出時,確保每個簽名之間有空行,縮排使用四個空格。這部分是根據序列化後的實現行為手動加入空行。
- 若輸入不符合資料限制(如頂層宣告包含非函式、類、介面,或內部宣告不符合要求),則返回 "ILLEGAL INPUT"。
模式匹配型別的語法
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)。對於函式, 作用域是函式簽名和函式體這個整體,對於類也完全類似。
實現步驟
- 將輸入程式碼
code
解析為 AST。 - 解析
path
引數(如 "函式名", "類名", "類名.函式名")以定位目標作用域。 - 遍歷
program.decls
找到路徑的第一部分匹配的頂層宣告(函式或類)。 - 如果路徑包含 "." (如 "類名.函式名"),則在找到的類宣告內部繼續查詢匹配的函式名。
- 一旦定位到目標作用域(
FuncDecl
或ClassDecl
節點),將其轉換為Tokens
。 - 遍歷這些
Tokens
,當遇到TokenKind.IDENTIFIER
且其value
等於oldName
時,將其替換為一個新的Token
,其kind
仍為TokenKind.IDENTIFIER
,但value
為newName
。 - 將修改後的
Tokens
重新組合成字串。 - 如果
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()
的結果進行替換:
- 路徑唯一且合法;
- 作用域明確且無變數遮蔽,不考慮形參與內部變數衝突;
- 不會過載函式;
- 僅透過詞法替換實現重構;
- 被替換的
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 引數指定程式碼中的一個類或者函式,然後為其生成解釋文件(以塊註釋的形式展示),用於解釋該類或者函式的功能。
實現步驟
- 初始化 LLM 類,配置了 API 地址、從
.key.txt
檔案讀取的金鑰、使用的模型名稱(如Qwen/Qwen3-8B
)以及啟用記憶功能; - 為 LLM 設定了一個系統級指令;
- 構建一個傳送給 LLM 的具體提示詞(prompt),包含指令以及使用者提供的完整程式碼;
- 將此提示傳送給 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 的自定義解析器)整合。
實現思路
- 使用
cangjieLex(code)
和parseProgram(tokens)
得到 AST (Program
型別)。 - 設計一系列遞迴的
toJson
轉換函式,每個函式對應一種 AST 節點型別。 - 每個
toJson
函式會建立一個JsonObject
。將節點型別和節點的屬性作為鍵值對新增到JsonObject
中。 - 如果節點的屬性本身也是 AST 節點(如函式體
Block
是一個節點,它包含一系列Stmt
節點組成的陣列),則遞迴呼叫相應的toJson
函式處理這些子節點,並將結果(JsonObject | JsonArray
)作為父物件的屬性值。 - 基本型別的屬性直接作為 JSON 值。
Token
型別的資訊,如位置(beginPos
,endPos
),也一併轉換為 JSON 物件。 program.decls
是一個ArrayList<Decl>
,會被轉換成一個JsonArray
,陣列中的每個元素是對應Decl
節點轉換後的JsonObject
。- 最終,整個
Program
AST 被轉換為一個頂層的JsonObject
或JsonArray
,然後序列化為 JSON。
為所有 AST 節點型別編寫轉換函式會導致程式碼量較大,編譯時間也相應增加。即使用了宏,程式碼量也達到了驚人的上千行。這堆宏具體也是在網頁文件中 document.querySelectorAll()
所有標題後複製出來,批次編輯了後寫了個 Python 生成的。

程式碼生成也是一種超程式設計。
為什麼這麼想不開呢?這是因為文件裡說:
在倉頡程式語言中,所有的泛型都是不型變的。這意味著如果 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 專案需要使用 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
。
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。
評論