跳到主要内容

OpenUI Lang — 语言与文法

本章讲什么: 先把 OpenUI Lang 这门语言「认全」——它有哪些语句、表达式、特殊调用;再讲文本怎么经「词法 → 表达式解析 → 语句分类」变成内部的 AST。这是后面所有章节的共同词汇。


1. 这门语言长什么样(零基础)

OpenUI Lang 的全部语法可以浓缩成一句话:「每行一条 名字 = 表达式 的语句,表达式可以是字面量、组件调用、或运算式。」

一个完整例子(# 示意,展示语言全貌):

$days = "7" // ① 可变状态变量
sales = Query("get_sales", {days: $days}, {rows: []}) // ② 数据查询
total = @Sum(sales.rows.value) // ③ 内置函数 + 数组拔字段
root = Card([picker, kpi, chart]) // ④ 入口,组件树
picker = Select("days", [SelectItem("7","7天"), SelectItem("30","30天")], null, null, $days)
kpi = TextContent("合计:" + total) // ⑤ 字符串拼接
chart = BarChart(sales.rows.label, sales.rows.value)

语言里只有几类「成分」,逐一认识:

成分写法含义
值语句name = Expr定义一个可被别处引用的名字
状态变量$name = default可读写的响应式变量(双向绑定用)
组件调用Card(arg1, arg2)PascalCase 名 + 位置参数
数据查询x = Query(tool, args, defaults, refresh?)声明一次读取
写操作x = Mutation(tool, args)声明一次写(按钮触发)
内置函数@Sum(...)@Each(...)@ 前缀,数据/迭代工具
引用chart(裸名字)引用另一条语句的值
表达式a + bx ? y : zobj.fieldarr[0]运算、三元、成员/下标访问

两条最容易踩的规则

  1. 参数按位置,不按名字。 Stack([children], "row", "l") 对;Stack([children], direction:"row") 错且会被静默吞掉。这是省 token 的核心代价。
  2. 每个名字(除 root)必须被别人引用,否则被丢弃。 定义了 chart 却没放进任何父组件的 children,它不会渲染——这是「可达性」规则,见第 02 章。

大小写有意义:PascalCase = 组件类型名,小写开头 = 变量引用。词法器靠首字母区分(parser/lexer.ts:347-349)。


2. AST:文本最终变成什么

解析的终点是一棵 AST(抽象语法树)。OpenUI Lang 的 AST 节点是一个判别联合,k 字段是判别符(parser/ast.ts:30-47,ASTNode)。挑关键几类:

k代表例子
Comp组件/函数调用Header("Hi")
Str/Num/Bool/Null字面量"hi" / 42
Arr/Obj数组/对象[a,b] / {k:v}
Ref引用另一条语句chart
StateRef$变量 引用$days
RuntimeRef指向 Query/Mutation(运行时解析)
BinOp/UnaryOp/Ternary运算 / 取反 / 三元a+b / !x / c?a:b
Member/Indexobj.field / arr[0]
Assign$x = ...(状态赋值)

一个关键区分:有些节点在解析时就能算成死值,有些必须留到运行时。后者叫「运行时表达式节点」(StateRef/RuntimeRef/BinOp/Ternary/Member/Index/Assign/UnaryOp),由 isRuntimeExpr 判定(parser/ast.ts:66-80)。它们会原样保留进 ElementNode 的 props,等第 04 章的 evaluator 来算。


3. 三步:从文本到 AST

一次 parse(input) 内部走三步(parser/parser.ts:406-426,parse):

原始文本
│ preprocess: 去 markdown 围栏 + 去注释 + trim

① autoClose ── 补全未闭合的串/括号(流式安全)


② tokenize ── 词法:切成 Token 流


③ split ── 按 depth-0 换行切成「一条条 RawStmt」


对每条:parseExpression(Pratt) → classifyStatement


Map<id, Statement> ── 符号表
│ materialize(见第 02 章)

ParseResult { root, meta, stateDeclarations, queryStatements, ... }

下面拆开 ②③ 和分类这步;autoClosematerialize 放第 02 章细讲。

3.1 词法:tokenize

它要解决的小问题:Header("Hi") 这串字符切成 [Type(Header), LParen, Str(Hi), RParen]

tokenize(parser/lexer.ts:13)是一个手写的单遍扫描器,逐字符走。几个值得记的设计:

  • 换行是有意义的 token(T.Newline),因为语句靠换行分隔——不像多数语言把换行当空白(lexer.ts:26-30)。
  • 字符串用 JSON.parse 解转义:遇到 "..." 直接交给原生 JSON 解析器处理 \n/\t/\uXXXX;流式途中若没闭合,先补个 " 再解析(lexer.ts:206-216)。
  • $fooStateVar@fooBuiltinCall、PascalCase → Type、小写 → Ident(lexer.ts:300-372)。
  • 负号有歧义消解:-3 是负数字面量还是减法,取决于前一个 token 是不是「值」(lexer.ts:252-276)。

3.2 切语句:split

它要解决的小问题: 一段多行文本里,哪几行是各自独立的语句?

split(parser/statements.ts:72)在 token 流上找「深度为 0 的换行」作为语句边界——括号 ()[]{} 里的换行不算。它额外处理一个坑:多行三元表达式。如果换行后下一个有意义 token 是 ?:,说明三元还没写完,不切(statements.ts:107-119)。无 = 或无标识符的行被静默跳过。

每条切出来是个 RawStmt { id, idTokenType, tokens }——注意它记下了 id 的 token 类型,这正是下一步分类的依据。

3.3 表达式解析:Pratt

它要解决的小问题:total + 1 > 5 ? "高" : "低" 这串 token 按正确优先级建成树。

parseExpression(parser/expressions.ts:24)是一个 Pratt 解析器(自顶向下的运算符优先级解析:每个运算符有个绑定力 minPrec,左边先吃完同级或更高级的)。优先级从三元(最低)到成员访问(最高)分 9 级(expressions.ts:10-18)。这种写法的好处是加新运算符只需在表里登记一格,不必改文法结构。

3.4 分类:classifyStatement

它要解决的小问题: 同样是 x = Foo(...),到底是组件、查询、还是状态声明?

classifyStatement(parser/parser.ts:56)在解析时就把每条语句归成四类之一(parser/ast.ts:163-167,Statement):

expr 是 Comp 且 name=="Query" → kind: "query" (并预抽 $依赖)
expr 是 Comp 且 name=="Mutation" → kind: "mutation"
id 的 token 是 StateVar($) → kind: "state"
其它 → kind: "value"

注意顺序:先判 Query/Mutation,再判 $,这样 $foo = Query(...) 也能正确归为 query 而非 state(parser.ts:57)。这一步把「是什么」固化进类型,下游就不用再到处 id.startsWith("$")name==="Query" 地猜了——这是个值得借鉴的「在边界处一次性归类」模式。

对 query 语句,这里还顺手调 collectQueryDeps 走一遍 AST,把 args 里引用的所有 $变量名预先抽出来(parser.ts:43-50)——这些就是「哪些状态一变就要重取」的依赖,留给运行时用。


4. 巧妙之处

  • 「位置参数 + 引用复用」是省 token 的语言级设计。 不写键名、把子组件抽成 name = ... 再引用,直接压缩输出长度——这不是优化,是语言定义本身(prompt.ts:syntaxRules 第 6 条强调位置参数)。
  • 分类在解析期完成。 Statement 联合让「query/mutation/state/value」成为类型层面的事实,消灭了下游一堆字符串判断(parser.ts:classifyStatement)。
  • 多行三元的前瞻。 split 和流式扫描都做了「换行后看下一个是不是 ?/:」的前瞻,让模型可以把三元拆成多行写而不被误切(statements.ts:107-119)。

5. 代码地图

主题文件符号
AST 节点定义packages/lang-core/src/parser/ast.tsASTNodeisRuntimeExprStatement
词法分析packages/lang-core/src/parser/lexer.tstokenize
Token 类型packages/lang-core/src/parser/tokens.tsT
切语句 + autoClosepackages/lang-core/src/parser/statements.tssplitautoClose
表达式解析(Pratt)packages/lang-core/src/parser/expressions.tsparseExpression
语句分类 + 入口packages/lang-core/src/parser/parser.tsclassifyStatementparsecollectQueryDeps
内置函数 / 保留调用packages/lang-core/src/parser/builtins.tsBUILTINSRESERVED_CALLSisBuiltin
类型(ParseResult 等)packages/lang-core/src/parser/types.tsParseResultElementNodeQueryStatementInfo