20260920173439

This commit is contained in:
oneao committed 2026-09-20 17:34:40 +08:00
1 parent f4bfc1ddfc
commit 6f03d92425
174 files changed
+23490 -157

No files matched your search

@@ -0,0 +1,228 @@
/**
* 表达式 AST(对应设计文档 §10)。
*
* 只有这一组节点类型可以被求值器执行,杜绝了运行任意代码的可能。
*/
export type NodeType =
| "Literal"
| "Identifier"
| "Member"
| "Index"
| "Call"
| "Unary"
| "Binary"
| "Logical"
| "Conditional"
/** 源码位置,用于诊断定位。 */
export interface SourceSpan {
start: number
end: number
}
interface BaseNode {
type: NodeType
span: SourceSpan
}
/** 字面量:数字 / 字符串 / 布尔 / null。 */
export interface LiteralNode extends BaseNode {
type: "Literal"
value: string | number | boolean | null
}
/** 标识符:作用域根,如 row、params、page。 */
export interface IdentifierNode extends BaseNode {
type: "Identifier"
name: string
}
/** 成员访问:a.b。 */
export interface MemberNode extends BaseNode {
type: "Member"
object: ExpressionNode
property: string
}
/** 下标访问:a[0]。 */
export interface IndexNode extends BaseNode {
type: "Index"
object: ExpressionNode
index: ExpressionNode
}
/** 函数调用:f(a, b)。 */
export interface CallNode extends BaseNode {
type: "Call"
callee: string
args: ExpressionNode[]
}
/** 一元运算:-a、!a。 */
export interface UnaryNode extends BaseNode {
type: "Unary"
operator: "-" | "+" | "!"
argument: ExpressionNode
}
/** 二元运算:a + b。 */
export interface BinaryNode extends BaseNode {
type: "Binary"
operator: "+" | "-" | "*" | "/" | "%" | "==" | "!=" | "===" | "!==" | ">" | ">=" | "<" | "<="
left: ExpressionNode
right: ExpressionNode
}
/** 逻辑运算:a && b、a || b、a ?? b。 */
export interface LogicalNode extends BaseNode {
type: "Logical"
operator: "&&" | "||" | "??"
left: ExpressionNode
right: ExpressionNode
}
/** 三元条件:cond ? a : b。 */
export interface ConditionalNode extends BaseNode {
type: "Conditional"
test: ExpressionNode
consequent: ExpressionNode
alternate: ExpressionNode
}
export type ExpressionNode =
| LiteralNode
| IdentifierNode
| MemberNode
| IndexNode
| CallNode
| UnaryNode
| BinaryNode
| LogicalNode
| ConditionalNode
/** 解析错误。 */
export interface ParseError {
message: string
span: SourceSpan
}
/** 解析结果。 */
export interface ParseResult {
ast: ExpressionNode | null
error: ParseError | null
}
/** 遍历 AST 的所有子节点,供分析与校验使用。 */
export function walkNode(
node: ExpressionNode,
visit: (child: ExpressionNode) => void,
): void {
switch (node.type) {
case "Literal":
case "Identifier":
return
case "Member":
visit(node.object)
return
case "Index":
visit(node.object)
visit(node.index)
return
case "Call":
for (const arg of node.args) visit(arg)
return
case "Unary":
visit(node.argument)
return
case "Binary":
case "Logical":
visit(node.left)
visit(node.right)
return
case "Conditional":
visit(node.test)
visit(node.consequent)
visit(node.alternate)
return
}
}
/**
* 收集表达式中引用的作用域根标识符。
*
* 例如 `row.amount + params.tax` 会返回 ["row", "params"]。
* 供宿主在绑定前校验作用域是否可用。
*/
export function collectRootIdentifiers(node: ExpressionNode): Set<string> {
const roots = new Set<string>()
const visitRoot = (current: ExpressionNode, isRoot: boolean): void => {
if (current.type === "Identifier") {
if (isRoot) roots.add(current.name)
return
}
switch (current.type) {
case "Member":
visitRoot(current.object, isRoot)
return
case "Index":
visitRoot(current.object, isRoot)
visitRoot(current.index, false)
return
case "Call":
for (const arg of current.args) visitRoot(arg, false)
return
case "Unary":
visitRoot(current.argument, false)
return
case "Binary":
case "Logical":
visitRoot(current.left, false)
visitRoot(current.right, false)
return
case "Conditional":
visitRoot(current.test, false)
visitRoot(current.consequent, false)
visitRoot(current.alternate, false)
return
case "Literal":
return
}
}
visitRoot(node, true)
return roots
}
/** 收集表达式调用的所有函数名。 */
export function collectFunctionNames(node: ExpressionNode): Set<string> {
const names = new Set<string>()
const visit = (current: ExpressionNode): void => {
if (current.type === "Call") names.add(current.callee)
walkNode(current, visit)
}
visit(node)
return names
}
/** 把 AST 重新打印为表达式源码,便于诊断信息展示。 */
export function printNode(node: ExpressionNode): string {
switch (node.type) {
case "Literal":
return typeof node.value === "string" ? JSON.stringify(node.value) : String(node.value)
case "Identifier":
return node.name
case "Member":
return `${printNode(node.object)}.${node.property}`
case "Index":
return `${printNode(node.object)}[${printNode(node.index)}]`
case "Call":
return `${node.callee}(${node.args.map(printNode).join(", ")})`
case "Unary":
return `${node.operator}${printNode(node.argument)}`
case "Binary":
case "Logical":
return `(${printNode(node.left)} ${node.operator} ${printNode(node.right)})`
case "Conditional":
return `(${printNode(node.test)} ? ${printNode(node.consequent)} : ${printNode(node.alternate)})`
}
}
@@ -0,0 +1,167 @@
/**
* 值绑定求值(对应设计文档 §6.2 的 ValueBinding 与 §10)。
*
* 把文档里的 ValueBinding { field, expression, format } 解析成最终值。
* 这是 report-layout 消费绑定的统一入口,保证字段与表达式两条路径
* 行为一致(都走受限求值器,字段只是 expression 的语法糖)。
*/
import type { Diagnostic } from "@report/core"
import type { ValueBinding } from "@report/core"
import { DIAGNOSTIC_CODES } from "@report/core"
import { compile, evaluateCompiled, type CompiledExpression } from "./evaluator.js"
import type { EvalScope } from "./scope.js"
/** 绑定求值选项。 */
export interface ResolveBindingOptions {
scope: EvalScope
elementId?: string
path?: string
}
/** 绑定求值结果。 */
export interface ResolveBindingResult {
value: unknown
/** 格式化后的展示文本。 */
text: string
diagnostics: Diagnostic[]
}
/**
* 编译缓存。
*
* 同一表达式在明细表里会对每一行求值一次,缓存解析结果避免重复解析。
* 使用 Map 而非模块级单例,由调用方决定生命周期。
*/
export class ExpressionCache {
private readonly cache = new Map<string, CompiledExpression>()
get(source: string): CompiledExpression {
const existing = this.cache.get(source)
if (existing) return existing
const compiled = compile(source)
this.cache.set(source, compiled)
return compiled
}
clear(): void {
this.cache.clear()
}
get size(): number {
return this.cache.size
}
}
/**
* 把 field 路径转为表达式源码。
*
* field 形如 "amount" 或 "customer.name",统一求值为 row.<field>。
*/
export function fieldToExpression(field: string): string {
return `row.${field}`
}
/**
* 解析一个值绑定。
*
* 优先级:expression > field。两者都缺省时返回空值。
* format 只影响 text,不影响 value,便于 Renderer 自行决定呈现方式。
*/
export function resolveBinding(
binding: ValueBinding | undefined,
options: ResolveBindingOptions,
cache?: ExpressionCache,
): ResolveBindingResult {
if (!binding) {
return { value: null, text: "", diagnostics: [] }
}
const diagnostics: Diagnostic[] = []
let value: unknown = null
if (binding.expression) {
const compiled = cache ? cache.get(binding.expression) : compile(binding.expression)
const result = evaluateCompiled(compiled, options)
value = result.value
diagnostics.push(...result.diagnostics)
} else if (binding.field) {
const source = fieldToExpression(binding.field)
const compiled = cache ? cache.get(source) : compile(source)
const result = evaluateCompiled(compiled, options)
value = result.value
diagnostics.push(...result.diagnostics)
// 字段缺失通常意味着数据源与设计不一致,值得单独提示
if (value === undefined) {
diagnostics.push({
severity: "warning",
code: DIAGNOSTIC_CODES.DATA_DATASET_MISSING,
message: `字段 "${binding.field}" 在当前数据行中不存在`,
...(options.elementId !== undefined ? { elementId: options.elementId } :{}),
...(options.path !== undefined ? { path: options.path } :{}),
})
}
}
const text = formatValue(value, binding.format, options, cache)
diagnostics.push(...text.diagnostics)
return { value, text: text.text, diagnostics }
}
interface FormatResult {
text: string
diagnostics: Diagnostic[]
}
/**
* 格式化值。
*
* 缺省行为:null / undefined 渲染为空串,其余走 String()。
* 指定 format 时复用表达式的 format() 内置函数,保证与表达式内
* format() 的行为完全一致。
*/
export function formatValue(
value: unknown,
format: string | undefined,
options: ResolveBindingOptions,
cache?: ExpressionCache,
): FormatResult {
if (value === null || value === undefined) return { text: "", diagnostics: [] }
if (!format) {
return { text: defaultStringify(value), diagnostics: [] }
}
// 复用 format() 内置函数,避免两套格式化逻辑产生分歧
const source = `format(row.__value, ${JSON.stringify(format)})`
const compiled = cache ? cache.get(source) : compile(source)
const result = evaluateCompiled(compiled, {
...options,
scope: {
...options.scope,
row: { ...(options.scope.row ?? {}), __value: value },
},
})
return {
text: result.value === null ? "" : defaultStringify(result.value),
diagnostics: result.diagnostics,
}
}
function defaultStringify(value: unknown): string {
if (value === null || value === undefined) return ""
if (typeof value === "string") return value
if (typeof value === "number") return Number.isFinite(value) ? String(value) : ""
if (typeof value === "boolean") return value ? "true" : "false"
if (value instanceof Date) return value.toISOString()
// 对象与数组不做隐式展开,避免把内部结构写进报表
return ""
}
/** 判断绑定是否为空(无 field 也无 expression)。 */
export function isBindingEmpty(binding: ValueBinding | undefined): boolean {
if (!binding) return true
return !binding.field && !binding.expression
}
@@ -0,0 +1,355 @@
/**
* 内置函数(对应设计文档 §10 与 §4.2)。
*
* V1 至少支持:
* 字段:row.amount
* 参数:params.customerName
* 简单运算:row.price * row.quantity
* 条件:if(row.amount > 0, "有", "无")
* 格式化:format(row.date, "YYYY-MM-DD")
*
* 所有函数都是纯函数,除参数外不接触任何外部状态。
*/
/** 函数实现签名。 */
export type BuiltinFunction = (args: unknown[]) => unknown
function toNumber(value: unknown): number {
if (typeof value === "number") return value
if (typeof value === "boolean") return value ? 1 : 0
if (typeof value === "string") {
const trimmed = value.trim()
if (trimmed === "") return 0
const parsed = Number(trimmed)
return Number.isNaN(parsed) ? Number.NaN : parsed
}
if (value === null || value === undefined) return 0
return Number.NaN
}
function toString(value: unknown): string {
if (value === null || value === undefined) return ""
if (typeof value === "string") return value
if (typeof value === "number" || typeof value === "boolean") return String(value)
// 对象不做隐式序列化,避免把内部结构意外写进报表
return ""
}
/** 把 Date 或可解析的日期值规范化为 Date。 */
function toDate(value: unknown): Date | null {
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
if (typeof value === "number") {
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
if (typeof value === "string" && value.trim() !== "") {
// 优先接受 ISO;再尝试 YYYY-MM-DD / YYYY/MM/DD
const iso = new Date(value)
if (!Number.isNaN(iso.getTime())) return iso
}
return null
}
function pad(value: number, length = 2): string {
return String(Math.abs(value)).padStart(length, "0")
}
/**
* 按模式格式化日期。
*
* 支持 YYYY MM DD HH mm ss SSS,以及常见的 M D H m s 简写。
* 小时使用 HH(24 小时制),避免 AM/PM 带来的歧义。
*/
function formatDate(date: Date, pattern: string): string {
const tokens: Record<string, string> = {
YYYY: String(date.getFullYear()),
MM: pad(date.getMonth() + 1),
DD: pad(date.getDate()),
HH: pad(date.getHours()),
mm: pad(date.getMinutes()),
ss: pad(date.getSeconds()),
SSS: String(date.getMilliseconds()).padStart(3, "0"),
M: String(date.getMonth() + 1),
D: String(date.getDate()),
H: String(date.getHours()),
}
// 长 token 优先,防止 MM 被 M 抢先匹配
return pattern.replace(/YYYY|SSS|MM|DD|HH|mm|ss|M|D|H/g, (match) => tokens[match] ?? match)
}
/**
* 按模式格式化数字。
*
* 支持 #,##0.00 这类千分位与小位数模式,以及 0%、0.0% 百分比。
*/
function formatNumber(value: number, pattern: string): string {
if (!Number.isFinite(value)) return ""
// 百分比模式
if (pattern.includes("%")) {
const decimals = (pattern.split(".")[1] ?? "").replace("%", "").length
return `${(value * 100).toFixed(decimals)}%`
}
const decimalPart = pattern.split(".")[1] ?? ""
const decimals = decimalPart.replace(/[^0#]/g, "").length
const useGrouping = pattern.includes(",")
let text = value.toFixed(decimals)
if (useGrouping) {
const negative = text.startsWith("-")
if (negative) text = text.slice(1)
const [intPart = "", fracPart] = text.split(".")
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
text = fracPart !== undefined ? `${grouped}.${fracPart}` : grouped
if (negative) text = `-${text}`
}
return text
}
/** 内置函数表。必须与 parser 的白名单保持一致。 */
export const BUILTINS: Record<string, BuiltinFunction> = {
/** 条件:if(cond, a, b)。惰性由求值器保证。 */
if: (args) => (args[0] ? args[1] : args[2]),
/** 逻辑与:全部为真才为真。 */
and: (args) => args.every((a) => Boolean(a)),
/** 逻辑或:任一为真即为真。 */
or: (args) => args.some((a) => Boolean(a)),
/** 逻辑非。 */
not: (args) => !args[0],
/** 字符串拼接,null/undefined 视为空串。 */
concat: (args) => args.map(toString).join(""),
/**
* 格式化:format(value, pattern)。
*
* 值是日期时按日期模式格式化,是数字时按数字模式格式化。
*/
format: (args) => {
const value = args[0]
const pattern = toString(args[1])
if (!pattern) return toString(value)
const date = toDate(value)
// 含日期 token 的模式走日期格式化;纯数字模式走数字格式化
if (date && /Y|M|D|H|m|s/.test(pattern)) {
return formatDate(date, pattern)
}
if (typeof value === "number" || (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value)))) {
return formatNumber(toNumber(value), pattern)
}
return toString(value)
},
round: (args) => {
const value = toNumber(args[0])
const digits = args.length > 1 ? toNumber(args[1]) : 0
if (!Number.isFinite(value)) return null
const factor = 10 ** digits
return Math.round(value * factor) / factor
},
floor: (args) => {
const value = toNumber(args[0])
return Number.isFinite(value) ? Math.floor(value) : null
},
ceil: (args) => {
const value = toNumber(args[0])
return Number.isFinite(value) ? Math.ceil(value) : null
},
abs: (args) => {
const value = toNumber(args[0])
return Number.isFinite(value) ? Math.abs(value) : null
},
/**
* 最小值。
*
* 同时支持两种调用形态:
* - 标量比较:min(3, 1, 2) → 1
* - 数组聚合:min(group.amounts) → 数组中的最小值
* 单参数且为数组时按聚合处理,否则按逐参数比较。
*/
min: (args) => {
const nums = collectNumbers(args)
return nums.length > 0 ? Math.min(...nums) : null
},
/** 最大值。调用形态同 min。 */
max: (args) => {
const nums = collectNumbers(args)
return nums.length > 0 ? Math.max(...nums) : null
},
len: (args) => {
const value = args[0]
if (typeof value === "string") return value.length
if (Array.isArray(value)) return value.length
if (value === null || value === undefined) return 0
return toString(value).length
},
upper: (args) => toString(args[0]).toUpperCase(),
lower: (args) => toString(args[0]).toLowerCase(),
trim: (args) => toString(args[0]).trim(),
/** 截取子串:substr(text, start, length?)。start 从 0 开始。 */
substr: (args) => {
const text = toString(args[0])
const start = Math.max(0, Math.trunc(toNumber(args[1])))
if (args.length < 3) return text.slice(start)
const length = Math.max(0, Math.trunc(toNumber(args[2])))
return text.slice(start, start + length)
},
/**
* 替换:replace(text, search, replacement)。
*
* 只做字面量替换,不接受正则,避免 ReDoS 与转义歧义。
*/
replace: (args) => {
const text = toString(args[0])
const search = toString(args[1])
const replacement = toString(args[2])
if (search === "") return text
return text.split(search).join(replacement)
},
isnull: (args) => args[0] === null || args[0] === undefined || args[0] === "",
/** 返回第一个非空的参数。 */
coalesce: (args) => {
for (const arg of args) {
if (arg !== null && arg !== undefined && arg !== "") return arg
}
return null
},
/** 转数字,失败返回 null 而不是 NaN,便于报表侧用 coalesce 兜底。 */
number: (args) => {
const value = toNumber(args[0])
return Number.isFinite(value) ? value : null
},
string: (args) => toString(args[0]),
/**
* 构造日期:date(value)。
*
* 返回 ISO 字符串而非 Date 对象,保证表达式结果始终是可序列化的纯数据。
*/
date: (args) => {
const date = toDate(args[0])
return date ? date.toISOString() : null
},
// -------------------------------------------------------------------------
// 聚合函数
//
// 聚合的语义约定(与分组配合使用):
// - 参数是数组时,直接对数组元素聚合,如 sum(group.rows.amount) 不成立,
// 应写 sum(group.amounts)。
// - 参数是标量时,视为"单元素聚合",等价于对该值本身取聚合。
// - 空数组/无有效值:sum 返回 0,avg/min/max 返回 null(避免除零与伪值),
// count 返回 0。
// 这样设计的原因是:报表里合计为 0 与"没有数据"是两种不同语义,
// 前者应显示 0,后者应显示空白。
// -------------------------------------------------------------------------
/** 求和。非数字元素被忽略。 */
sum: (args) => {
const list = toAggregateList(args[0])
let total = 0
for (const item of list) {
const n = toNumber(item)
if (Number.isFinite(n)) total += n
}
return total
},
/**
* 平均值。无有效值时返回 null。
*
* 空值(null/undefined)与空字符串会被完全忽略,不参与分子也不参与分母。
* 这一点与 sum 不同:sum 把 null 视为 0 是正确的(合计里空值就是没贡献),
* 但 avg 若把 null 当 0 计入分母,会把平均值稀释成偏小的错误数字。
*/
avg: (args) => {
const list = toAggregateList(args[0])
let total = 0
let count = 0
for (const item of list) {
if (item === null || item === undefined) continue
if (typeof item === "string" && item.trim() === "") continue
const n = toNumber(item)
if (Number.isFinite(n)) {
total += n
count += 1
}
}
return count > 0 ? total / count : null
},
/** 计数。无参数时统计元素个数;有参数时统计非空值个数。 */
count: (args) => {
if (args.length === 0) return 0
const list = toAggregateList(args[0])
// 标量参数(非数组)视为 1 个元素
if (!Array.isArray(args[0])) {
const value = args[0]
return value === null || value === undefined ? 0 : 1
}
// 单参数形式 count(list):统计非空元素
if (args.length === 1) {
let n = 0
for (const item of list) {
if (item !== null && item !== undefined) n += 1
}
return n
}
return list.length
},
}
/**
* 把聚合参数规范化为数组。
*
* 数组原样返回;标量包装成单元素数组;null/undefined 返回空数组。
*/
function toAggregateList(value: unknown): unknown[] {
if (Array.isArray(value)) return value
if (value === null || value === undefined) return []
return [value]
}
/**
* 从实参列表收集有限数字,兼容两种调用形态:
* - 多参数:min(3, 1, 2) —— 逐个转换
* - 单参数数组:min(group.amounts) —— 展开数组转换
*/
function collectNumbers(args: unknown[]): number[] {
const out: number[] = []
// 单参数且为数组时按聚合处理
const source = args.length === 1 ? toAggregateList(args[0]) : args
for (const item of source) {
const n = toNumber(item)
if (Number.isFinite(n)) out.push(n)
}
return out
}
/** 判断函数名是否为内置函数。 */
export function isBuiltinFunction(name: string): boolean {
return Object.prototype.hasOwnProperty.call(BUILTINS, name)
}
/** 全部内置函数名。 */
export function builtinFunctionNames(): string[] {
return Object.keys(BUILTINS).sort()
}
@@ -0,0 +1,373 @@
/**
* 受限求值器(对应设计文档 §10、§4.2)。
*
* 明确禁止通过 eval 或 new Function 执行任意 JavaScript。
*
* 本文件是"受限"这一约束的落地点,安全边界如下:
* 1. 只处理 ast.ts 中定义的节点类型,没有其它可执行形式。
* 2. 作用域根只能命中 scope.ts 的白名单,拿不到 window / globalThis / process。
* 3. 属性访问拒绝 __proto__ / constructor / prototype,阻断原型链逃逸。
* 4. 函数调用只查 builtins 表,且 parser 已在解析期做过白名单过滤。
* 5. 求值过程中任何异常都被捕获并转成诊断,不会向上抛。
*/
import type { Diagnostic } from "@report/core"
import { DIAGNOSTIC_CODES } from "@report/core"
import type { ExpressionNode } from "./ast.js"
import { printNode } from "./ast.js"
import { BUILTINS } from "./builtins.js"
import { parse } from "./parser.js"
import { resolveRoot, safeGet, type EvalScope } from "./scope.js"
/** 求值选项。 */
export interface EvalOptions {
/** 作用域。 */
scope: EvalScope
/** 出错的元素 ID,用于诊断定位。 */
elementId?: string
/** 字段路径,用于诊断定位。 */
path?: string
/**
* 表达式执行的时间上限(毫秒)。
*
* 由于语法受限、无循环结构,表达式天然会终止;该选项仅作为
* 宿主对异常输入的最后一道保护,默认不启用。
*/
timeoutMs?: number
}
/** 求值结果。 */
export interface EvalResult {
value: unknown
diagnostics: Diagnostic[]
}
/**
* 求值已解析的 AST。
*
* 不使用 eval / new Function,纯粹按节点类型递归求值。
*/
export function evaluateAst(node: ExpressionNode, options: EvalOptions): EvalResult {
const diagnostics: Diagnostic[] = []
const context = {
scope: options.scope,
...(options.elementId !== undefined ? { elementId: options.elementId } :{}),
...(options.path !== undefined ? { path: options.path } :{}),
report(message: string, span?: { start: number; end: number }): void {
diagnostics.push({
severity: "warning",
code: DIAGNOSTIC_CODES.EXPRESSION_EVAL_ERROR,
message,
...(options.elementId !== undefined ? { elementId: options.elementId } :{}),
...(options.path !== undefined ? { path: options.path } :{}),
})
void span
},
}
const value = evalNode(node, context)
return { value, diagnostics }
}
/** 内部求值上下文。 */
interface EvalContext {
scope: EvalScope
elementId?: string
path?: string
report(message: string, span?: { start: number; end: number }): void
}
function evalNode(node: ExpressionNode, ctx: EvalContext): unknown {
switch (node.type) {
case "Literal":
return node.value
case "Identifier":
return resolveRoot(ctx.scope, node.name)
case "Member": {
const object = evalNode(node.object, ctx)
return safeGet(object, node.property)
}
case "Index": {
const object = evalNode(node.object, ctx)
const index = evalNode(node.index, ctx)
return safeGet(object, String(index))
}
case "Unary": {
const value = evalNode(node.argument, ctx)
switch (node.operator) {
case "-": {
const n = toNumberStrict(value)
return n === null ? null : -n
}
case "+": {
const n = toNumberStrict(value)
return n === null ? 0 : n
}
case "!":
return !toBoolean(value)
}
return null
}
case "Binary":
return evalBinary(node, ctx)
case "Logical": {
const left = evalNode(node.left, ctx)
switch (node.operator) {
case "&&":
// 短路:左值为假就不再求值右侧,避免无谓的错误诊断
return toBoolean(left) ? evalNode(node.right, ctx) : left
case "||":
return toBoolean(left) ? left : evalNode(node.right, ctx)
case "??":
return left === null || left === undefined ? evalNode(node.right, ctx) : left
}
return null
}
case "Conditional":
return toBoolean(evalNode(node.test, ctx))
? evalNode(node.consequent, ctx)
: evalNode(node.alternate, ctx)
case "Call":
return evalCall(node, ctx)
}
}
function evalBinary(
node: Extract<ExpressionNode, { type: "Binary" }>,
ctx: EvalContext,
): unknown {
const left = evalNode(node.left, ctx)
const right = evalNode(node.right, ctx)
switch (node.operator) {
case "+": {
// 一方是字符串则做拼接,否则做加法;null/undefined 视为 0
if (typeof left === "string" || typeof right === "string") {
return toText(left) + toText(right)
}
// null / undefined 视为 0,与 toNumberStrict("") === 0 保持一致,
// 避免报表里出现空单元格导致整列求和变空。
const a = left === null || left === undefined ? 0 : toNumberStrict(left)
const b = right === null || right === undefined ? 0 : toNumberStrict(right)
if (a === null || b === null) return null
return a + b
}
case "-":
case "*":
case "/":
case "%": {
// 减法/乘法中 null 同样视为 0;除法与取模的除数仍按 0 处理并报诊断
const a = left === null || left === undefined ? 0 : toNumberStrict(left)
const b = right === null || right === undefined ? 0 : toNumberStrict(right)
if (a === null || b === null) return null
if ((node.operator === "/" || node.operator === "%") && b === 0) {
// 除零返回 null 并给出诊断,避免产生 Infinity 污染报表
ctx.report("除数为 0,结果按空值处理", node.span)
return null
}
switch (node.operator) {
case "-": return a - b
case "*": return a * b
case "/": return a / b
default: return a % b
}
}
case "==":
case "===":
return looseEquals(left, right, node.operator === "===")
case "!=":
case "!==":
return !looseEquals(left, right, node.operator === "!==")
case ">":
case ">=":
case "<":
case "<=":
return compare(left, right, node.operator)
}
}
function evalCall(
node: Extract<ExpressionNode, { type: "Call" }>,
ctx: EvalContext,
): unknown {
const fn = BUILTINS[node.callee]
if (!fn) {
// 正常情况下 parser 已拦截,这里作为纵深防御
ctx.report(`不允许调用函数 "${node.callee}"`, node.span)
return null
}
// if 惰性求值:只求值命中的分支,避免未选中分支的错误影响结果
if (node.callee === "if") {
const test = evalNode(node.args[0]!, ctx)
return toBoolean(test)
? node.args[1]
? evalNode(node.args[1], ctx)
: null
: node.args[2]
? evalNode(node.args[2], ctx)
: null
}
const args = node.args.map((arg) => evalNode(arg, ctx))
try {
return fn(args)
} catch (cause) {
ctx.report(`函数 ${node.callee} 执行失败:${(cause as Error).message}`, node.span)
return null
}
}
function toBoolean(value: unknown): boolean {
if (value === null || value === undefined) return false
if (typeof value === "boolean") return value
if (typeof value === "number") return value !== 0 && !Number.isNaN(value)
if (typeof value === "string") return value.length > 0
if (Array.isArray(value)) return value.length > 0
return true
}
function toText(value: unknown): string {
if (value === null || value === undefined) return ""
if (typeof value === "string") return value
if (typeof value === "number" || typeof value === "boolean") return String(value)
return ""
}
/** 严格转数字,失败返回 null(而不是 NaN)。 */
function toNumberStrict(value: unknown): number | null {
if (typeof value === "number") return Number.isNaN(value) ? null : value
if (typeof value === "boolean") return value ? 1 : 0
if (typeof value === "string") {
const trimmed = value.trim()
if (trimmed === "") return 0
const parsed = Number(trimmed)
return Number.isNaN(parsed) ? null : parsed
}
if (value === null || value === undefined) return null
return null
}
function looseEquals(a: unknown, b: unknown, strict: boolean): boolean {
if (a === b) return true
if (a === null || a === undefined) return b === null || b === undefined
if (b === null || b === undefined) return false
if (strict) return false
// 宽松比较:数值优先,其次字符串
const na = toNumberStrict(a)
const nb = toNumberStrict(b)
if (na !== null && nb !== null) return na === nb
return toText(a) === toText(b)
}
function compare(a: unknown, b: unknown, operator: string): boolean {
// 两边都能转数字时按数值比较,否则按字符串比较(支持日期串)
const na = toNumberStrict(a)
const nb = toNumberStrict(b)
const bothNumeric = na !== null && nb !== null
let result: number
if (bothNumeric) {
result = na - nb
} else {
const sa = toText(a)
const sb = toText(b)
result = sa < sb ? -1 : sa > sb ? 1 : 0
}
switch (operator) {
case ">": return result > 0
case ">=": return result >= 0
case "<": return result < 0
default: return result <= 0
}
}
/**
* 一步到位:解析并求值表达式字符串。
*
* 单个字段出错不应导致整个文档无结果(§10),因此这里总是返回结果对象,
* 错误体现在 diagnostics 中。
*/
export function evaluateExpression(source: string, options: EvalOptions): EvalResult {
const { ast, error } = parse(source)
if (!ast || error) {
return {
value: null,
diagnostics: [
{
severity: "warning",
code: DIAGNOSTIC_CODES.EXPRESSION_PARSE_ERROR,
message: `表达式解析失败:${error?.message ?? "未知错误"}(${source})`,
...(options.elementId !== undefined ? { elementId: options.elementId } :{}),
...(options.path !== undefined ? { path: options.path } :{}),
},
],
}
}
return evaluateAst(ast, options)
}
/**
* 预编译表达式。
*
* Designer 里同一表达式会被反复求值(每一行一次),预编译避免重复解析。
*/
export function compile(source: string): CompiledExpression {
const { ast, error } = parse(source)
return {
source,
ast,
error: error ? error.message : null,
}
}
/** 已编译表达式。 */
export interface CompiledExpression {
source: string
ast: ExpressionNode | null
error: string | null
}
/** 求值已编译表达式。 */
export function evaluateCompiled(
compiled: CompiledExpression,
options: EvalOptions,
): EvalResult {
if (!compiled.ast) {
return {
value: null,
diagnostics: [
{
severity: "warning",
code: DIAGNOSTIC_CODES.EXPRESSION_PARSE_ERROR,
message: `表达式解析失败:${compiled.error ?? "未知错误"}(${compiled.source})`,
...(options.elementId !== undefined ? { elementId: options.elementId } :{}),
...(options.path !== undefined ? { path: options.path } :{}),
},
],
}
}
return evaluateAst(compiled.ast, options)
}
/** 便捷函数:只关心值,忽略诊断。 */
export function evaluateValue(source: string, scope: EvalScope): unknown {
return evaluateExpression(source, { scope }).value
}
/** 打印 AST 回源码,便于诊断展示。 */
export { printNode }
@@ -0,0 +1,72 @@
/**
* @report/expression 公共入口(对应设计文档 §4.2、§10)。
*
* 表达式系统使用受限语法和 AST,不执行任意 JavaScript。
* 本包只依赖 @report/core 的类型,不依赖 DOM 或任何求值框架。
*/
// 词法
export type { Token, TokenType, TokenizeError, TokenizeResult } from "./tokenizer.js"
export { tokenize, KEYWORDS } from "./tokenizer.js"
// AST
export type {
ExpressionNode,
NodeType,
SourceSpan,
LiteralNode,
IdentifierNode,
MemberNode,
IndexNode,
CallNode,
UnaryNode,
BinaryNode,
LogicalNode,
ConditionalNode,
ParseError,
ParseResult,
} from "./ast.js"
export {
walkNode,
collectRootIdentifiers,
collectFunctionNames,
printNode,
} from "./ast.js"
// 语法
export { parse, isValidExpression, allowedFunctionNames } from "./parser.js"
// 作用域
export type { EvalScope, ScopeRoot } from "./scope.js"
export { SCOPE_ROOTS, resolveRoot, safeGet } from "./scope.js"
// 内置函数
export type { BuiltinFunction } from "./builtins.js"
export { BUILTINS, isBuiltinFunction, builtinFunctionNames } from "./builtins.js"
// 求值
export type {
EvalOptions,
EvalResult,
CompiledExpression,
} from "./evaluator.js"
export {
evaluateAst,
evaluateExpression,
evaluateCompiled,
evaluateValue,
compile,
} from "./evaluator.js"
// 绑定
export type {
ResolveBindingOptions,
ResolveBindingResult,
} from "./binding.js"
export {
ExpressionCache,
resolveBinding,
formatValue,
fieldToExpression,
isBindingEmpty,
} from "./binding.js"
@@ -0,0 +1,396 @@
/**
* 表达式解析器(对应设计文档 §10)。
*
* 手写递归下降解析器,优先级由低到高:
*
* conditional ?:
* logical-or ||
* logical-and &&
* nullish ??
* equality == != === !==
* relational > >= < <=
* additive + -
* multiplicative * / %
* unary - + !
* postfix . [] ()
* primary 字面量 / 标识符 / ( expr )
*
* 不使用 eval / new Function,也不生成可执行 JS 字符串。
*/
import type { ExpressionNode, ParseError, ParseResult, SourceSpan } from "./ast.js"
import { tokenize, type Token } from "./tokenizer.js"
/** 二元运算符优先级表。 */
const BINARY_PRECEDENCE: Record<string, number> = {
"||": 1,
"&&": 2,
"??": 3,
"==": 4,
"!=": 4,
"===": 4,
"!==": 4,
">": 5,
">=": 5,
"<": 5,
"<=": 5,
"+": 6,
"-": 6,
"*": 7,
"/": 7,
"%": 7,
}
class Parser {
private readonly tokens: Token[]
private index = 0
constructor(tokens: Token[]) {
this.tokens = tokens
}
private peek(): Token {
return this.tokens[this.index]!
}
private next(): Token {
const token = this.tokens[this.index]!
if (token.type !== "eof") this.index += 1
return token
}
private span(from: Token, to: Token): SourceSpan {
return { start: from.start, end: to.end }
}
/** 解析完整表达式,要求消费掉所有 token。 */
parseExpression(): ParseResult {
try {
const expr = this.parseConditional()
const rest = this.peek()
if (rest.type !== "eof") {
return {
ast: null,
error: {
message: `表达式末尾存在多余内容 "${rest.value}"`,
span: { start: rest.start, end: rest.end },
},
}
}
return { ast: expr, error: null }
} catch (cause) {
if (cause instanceof ParseFailure) {
return { ast: null, error: { message: cause.message, span: cause.span } }
}
throw cause
}
}
private fail(message: string, span: SourceSpan): never {
throw new ParseFailure(message, span)
}
private parseConditional(): ExpressionNode {
const test = this.parseBinary(0)
const token = this.peek()
if (token.type === "operator" && token.value === "?") {
this.next()
const consequent = this.parseConditional()
const colon = this.peek()
if (!(colon.type === "punctuation" && colon.value === ":")) {
this.fail('三元表达式缺少 ":"', { start: colon.start, end: colon.end })
}
this.next()
const alternate = this.parseConditional()
return {
type: "Conditional",
test,
consequent,
alternate,
span: this.span(token, colon),
}
}
return test
}
/** 优先级爬升法解析二元与逻辑运算。 */
private parseBinary(minPrecedence: number): ExpressionNode {
let left = this.parseUnary()
for (;;) {
const token = this.peek()
if (token.type !== "operator") break
const precedence = BINARY_PRECEDENCE[token.value]
if (precedence === undefined || precedence < minPrecedence) break
this.next()
// 左结合:右侧要求更高优先级
const right = this.parseBinary(precedence + 1)
if (token.value === "&&" || token.value === "||" || token.value === "??") {
left = {
type: "Logical",
operator: token.value,
left,
right,
span: { start: left.span.start, end: right.span.end },
}
} else {
left = {
type: "Binary",
operator: token.value as never,
left,
right,
span: { start: left.span.start, end: right.span.end },
}
}
}
return left
}
private parseUnary(): ExpressionNode {
const token = this.peek()
if (token.type === "operator" && (token.value === "-" || token.value === "+" || token.value === "!")) {
this.next()
const argument = this.parseUnary()
return {
type: "Unary",
operator: token.value as "-" | "+" | "!",
argument,
span: { start: token.start, end: argument.span.end },
}
}
return this.parsePostfix()
}
/** 后缀:成员访问、下标、函数调用。 */
private parsePostfix(): ExpressionNode {
let node = this.parsePrimary()
for (;;) {
const token = this.peek()
if (token.type === "punctuation" && token.value === ".") {
this.next()
const prop = this.peek()
if (prop.type !== "identifier") {
this.fail("成员访问的右侧必须是标识符", { start: prop.start, end: prop.end })
}
this.next()
node = {
type: "Member",
object: node,
property: prop.value,
span: { start: node.span.start, end: prop.end },
}
continue
}
if (token.type === "punctuation" && token.value === "[") {
this.next()
const index = this.parseConditional()
const close = this.peek()
if (!(close.type === "punctuation" && close.value === "]")) {
this.fail('下标访问缺少 "]"', { start: close.start, end: close.end })
}
this.next()
node = {
type: "Index",
object: node,
index,
span: { start: node.span.start, end: close.end },
}
continue
}
// 调用只允许直接调用具名函数:foo(...),不支持 obj.method(...)
if (token.type === "punctuation" && token.value === "(" && node.type === "Identifier") {
this.next()
const args: ExpressionNode[] = []
if (!(this.peek().type === "punctuation" && this.peek().value === ")")) {
for (;;) {
args.push(this.parseConditional())
const sep = this.peek()
if (sep.type === "punctuation" && sep.value === ",") {
this.next()
continue
}
break
}
}
const close = this.peek()
if (!(close.type === "punctuation" && close.value === ")")) {
this.fail('函数调用缺少 ")"', { start: close.start, end: close.end })
}
this.next()
// 非白名单函数在解析期就拒绝,避免运行期才暴露
if (!isAllowedFunctionName(node.name)) {
this.fail(`不允许调用函数 "${node.name}"`, node.span)
}
node = {
type: "Call",
callee: node.name,
args,
span: { start: node.span.start, end: close.end },
}
continue
}
break
}
return node
}
private parsePrimary(): ExpressionNode {
const token = this.peek()
switch (token.type) {
case "number":
this.next()
return {
type: "Literal",
value: token.numberValue!,
span: { start: token.start, end: token.end },
}
case "string":
this.next()
return {
type: "Literal",
value: token.stringValue!,
span: { start: token.start, end: token.end },
}
case "boolean":
this.next()
return {
type: "Literal",
value: token.value === "true",
span: { start: token.start, end: token.end },
}
case "null":
this.next()
return {
type: "Literal",
value: null,
span: { start: token.start, end: token.end },
}
case "identifier":
this.next()
if (token.value === "undefined") {
return {
type: "Literal",
value: null,
span: { start: token.start, end: token.end },
}
}
return {
type: "Identifier",
name: token.value,
span: { start: token.start, end: token.end },
}
case "punctuation":
if (token.value === "(") {
this.next()
const inner = this.parseConditional()
const close = this.peek()
if (!(close.type === "punctuation" && close.value === ")")) {
this.fail('括号未闭合', { start: close.start, end: close.end })
}
this.next()
return inner
}
break
case "eof":
this.fail("表达式意外结束", { start: token.start, end: token.end })
break
}
this.fail(`意外的 token "${token.value}"`, { start: token.start, end: token.end })
}
}
class ParseFailure extends Error {
readonly span: SourceSpan
constructor(message: string, span: SourceSpan) {
super(message)
this.name = "ParseFailure"
this.span = span
}
}
/**
* 允许调用的函数白名单。
*
* 与 builtins 的实现保持同步;解析期即拒绝未登记函数,
* 使非法表达式在任何求值之前就失败。
*/
const ALLOWED_FUNCTIONS = new Set([
"if",
"and",
"or",
"not",
"concat",
"format",
"round",
"floor",
"ceil",
"abs",
"min",
"max",
"len",
"upper",
"lower",
"trim",
"substr",
"replace",
"isnull",
"coalesce",
"number",
"string",
"date",
// 聚合函数(与分组配合使用)
"sum",
"avg",
"count",
])
function isAllowedFunctionName(name: string): boolean {
return ALLOWED_FUNCTIONS.has(name)
}
/** 列出全部受支持的函数名。 */
export function allowedFunctionNames(): string[] {
return [...ALLOWED_FUNCTIONS].sort()
}
/**
* 解析表达式为 AST。
*
* 词法或语法错误时返回 error,不抛异常。
*/
export function parse(source: string): ParseResult {
const { tokens, error } = tokenize(source)
if (error) {
return {
ast: null,
error: { message: error.message, span: { start: error.start, end: error.end } },
}
}
if (tokens.length === 1 && tokens[0]!.type === "eof") {
return { ast: null, error: { message: "表达式为空", span: { start: 0, end: 0 } } }
}
return new Parser(tokens).parseExpression()
}
/** 判断表达式是否可解析。 */
export function isValidExpression(source: string): boolean {
return parse(source).ast !== null
}
@@ -0,0 +1,101 @@
/**
* 表达式求值作用域(对应设计文档 §10)。
*
* 作用域根由宿主注入,表达式只能访问这里显式提供的值。
* 未提供的根名会解析为 undefined 并产生诊断,而不是去查全局对象,
* 因此 `window`、`globalThis`、`process` 这类标识符天然拿不到任何东西。
*/
/** 求值作用域。 */
export interface EvalScope {
/** 当前行数据,对应表达式里的 row。 */
row?: Record<string, unknown>
/** 报表参数,对应 params。 */
params?: Record<string, unknown>
/** 分组值,对应 group。 */
group?: Record<string, unknown>
/** 页面信息,对应 page。 */
page?: Record<string, unknown>
/** 当前行索引(从 0 开始),对应 index。 */
index?: number
/** 当前行序号(从 1 开始),对应 rownumber。 */
rowNumber?: number
/** 数据集聚合,对应 dataset。 */
dataset?: Record<string, unknown>
/** 自定义根,供宿主扩展。 */
extra?: Record<string, unknown>
}
/** 允许作为表达式根名的标识符。 */
export const SCOPE_ROOTS = [
"row",
"params",
"group",
"page",
"index",
"rownumber",
"dataset",
] as const
export type ScopeRoot = (typeof SCOPE_ROOTS)[number]
/**
* 取作用域根的值。
*
* 明确只认识白名单根名,其余一律返回 undefined。
* 这是"受限求值"的第一道闸门。
*/
export function resolveRoot(scope: EvalScope, name: string): unknown {
switch (name) {
case "row":
return scope.row
case "params":
return scope.params
case "group":
return scope.group
case "page":
return scope.page
case "index":
return scope.index
case "rownumber":
return scope.rowNumber
case "dataset":
return scope.dataset
default:
// 宿主自定义根只允许命中 extra 中真实存在的键
if (scope.extra && Object.prototype.hasOwnProperty.call(scope.extra, name)) {
return scope.extra[name]
}
return undefined
}
}
/**
* 安全地读取对象属性。
*
* 拒绝原型链上的危险键,避免 `row.constructor` / `row.__proto__`
* 这类写法拿到构造函数进而逃逸。
*/
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"])
export function safeGet(target: unknown, key: string): unknown {
if (FORBIDDEN_KEYS.has(key)) return undefined
if (target === null || target === undefined) return undefined
if (typeof target !== "object" && typeof target !== "string") return undefined
if (typeof target === "string") {
// 字符串只允许按索引取字符,不允许方法调用
if (key === "length") return target.length
return undefined
}
if (Array.isArray(target)) {
if (key === "length") return target.length
const index = Number(key)
if (Number.isInteger(index) && index >= 0 && index < target.length) {
return target[index]
}
return undefined
}
if (!Object.prototype.hasOwnProperty.call(target, key)) return undefined
return (target as Record<string, unknown>)[key]
}
@@ -0,0 +1,292 @@
/**
* 表达式词法单元(对应设计文档 §10)。
*
* 表达式系统使用受限语法和 AST,不执行任意 JavaScript。
* 词法层只产出可枚举的 token 类型,任何超出语法范围的字符都直接报错,
* 而不是"忽略后继续",从而杜绝绕过语法检查的可能。
*/
export type TokenType =
| "number"
| "string"
| "boolean"
| "null"
| "identifier"
| "operator"
| "punctuation"
| "eof"
export interface Token {
type: TokenType
/** 原始字面量文本。 */
value: string
/** 数字 token 的解析结果。 */
numberValue?: number
/** 字符串 token 的解析结果(已处理转义)。 */
stringValue?: string
/** 在源串中的起始偏移。 */
start: number
/** 在源串中的结束偏移(不含)。 */
end: number
/** 从 0 开始的行号,用于诊断定位。 */
line: number
/** 从 0 开始的列号。 */
column: number
}
/** 词法错误。 */
export interface TokenizeError {
message: string
start: number
end: number
line: number
column: number
}
/** 词法分析结果。 */
export interface TokenizeResult {
tokens: Token[]
error: TokenizeError | null
}
/** 运算符,按长度降序排列以保证最长匹配(如 >= 优先于 >)。 */
const OPERATORS = [
"===",
"!==",
"==",
"!=",
">=",
"<=",
"&&",
"||",
"??",
"?",
"+",
"-",
"*",
"/",
"%",
">",
"<",
"!",
] as const
const PUNCTUATION = ["(", ")", ",", ".", "[", "]", ":"] as const
/** 保留字:不可作为标识符使用。 */
export const KEYWORDS = new Set(["true", "false", "null", "undefined", "if", "and", "or", "not"])
function isDigit(ch: string): boolean {
return ch >= "0" && ch <= "9"
}
function isIdentStart(ch: string): boolean {
return (
(ch >= "a" && ch <= "z") ||
(ch >= "A" && ch <= "Z") ||
ch === "_" ||
ch === "$" ||
// 允许中文标识符,便于 params.客户名称 这类写法
ch.charCodeAt(0) > 0x7f
)
}
function isIdentPart(ch: string): boolean {
return isIdentStart(ch) || isDigit(ch)
}
function isSpace(ch: string): boolean {
return ch === " " || ch === "\t" || ch === "\r" || ch === "\n"
}
/**
* 词法分析。
*
* 遇到非法字符立即返回错误,不产出"尽力而为"的 token 流。
*/
export function tokenize(source: string): TokenizeResult {
const tokens: Token[] = []
let i = 0
let line = 0
let lineStart = 0
const position = (index: number): { line: number; column: number } => ({
line,
column: index - lineStart,
})
const fail = (message: string, start: number, end: number): TokenizeResult => {
const pos = position(start)
return {
tokens: [],
error: { message, start, end, line: pos.line, column: pos.column },
}
}
while (i < source.length) {
const ch = source[i]!
if (isSpace(ch)) {
if (ch === "\n") {
line += 1
lineStart = i + 1
}
i += 1
continue
}
const start = i
const pos = position(start)
// 数字:123、1.5、.5 不支持(必须写 0.5),避免与成员访问歧义
if (isDigit(ch)) {
let j = i
while (j < source.length && isDigit(source[j]!)) j += 1
if (source[j] === ".") {
j += 1
if (!isDigit(source[j] ?? "")) {
return fail("小数点后必须有数字", start, j)
}
while (j < source.length && isDigit(source[j]!)) j += 1
}
// 科学计数法
if (source[j] === "e" || source[j] === "E") {
let k = j + 1
if (source[k] === "+" || source[k] === "-") k += 1
if (isDigit(source[k] ?? "")) {
while (k < source.length && isDigit(source[k]!)) k += 1
j = k
}
}
const raw = source.slice(i, j)
tokens.push({
type: "number",
value: raw,
numberValue: Number(raw),
start,
end: j,
line: pos.line,
column: pos.column,
})
i = j
continue
}
// 字符串:单引号或双引号
if (ch === '"' || ch === "'") {
const quote = ch
let j = i + 1
let out = ""
let closed = false
while (j < source.length) {
const c = source[j]!
if (c === "\\") {
const next = source[j + 1]
if (next === undefined) return fail("字符串以转义符结尾", start, j + 1)
switch (next) {
case "n": out += "\n"; break
case "t": out += "\t"; break
case "r": out += "\r"; break
case "\\": out += "\\"; break
case '"': out += '"'; break
case "'": out += "'"; break
default:
// 未知转义一律原样保留反斜杠,避免静默吞掉字符
out += next
break
}
j += 2
continue
}
if (c === quote) {
closed = true
j += 1
break
}
if (c === "\n") {
return fail("字符串字面量不能跨行", start, j)
}
out += c
j += 1
}
if (!closed) return fail("字符串字面量未闭合", start, source.length)
tokens.push({
type: "string",
value: source.slice(start, j),
stringValue: out,
start,
end: j,
line: pos.line,
column: pos.column,
})
i = j
continue
}
// 标识符 / 关键字
if (isIdentStart(ch)) {
let j = i
while (j < source.length && isIdentPart(source[j]!)) j += 1
const raw = source.slice(i, j)
let type: TokenType = "identifier"
if (raw === "true" || raw === "false") type = "boolean"
else if (raw === "null") type = "null"
tokens.push({
type,
value: raw,
start,
end: j,
line: pos.line,
column: pos.column,
})
i = j
continue
}
// 运算符:最长匹配
const operator = OPERATORS.find((op) => source.startsWith(op, i))
if (operator) {
const j = i + operator.length
tokens.push({
type: "operator",
value: operator,
start,
end: j,
line: pos.line,
column: pos.column,
})
i = j
continue
}
// 标点
const punct = PUNCTUATION.find((p) => source.startsWith(p, i))
if (punct) {
const j = i + punct.length
tokens.push({
type: "punctuation",
value: punct,
start,
end: j,
line: pos.line,
column: pos.column,
})
i = j
continue
}
return fail(`无法识别的字符 "${ch}"`, start, start + 1)
}
const endPos = position(source.length)
tokens.push({
type: "eof",
value: "",
start: source.length,
end: source.length,
line: endPos.line,
column: endPos.column,
})
return { tokens, error: null }
}