20260920173439
This commit is contained in:
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 }
|
||||
}
|
||||
Reference in new issue
Block a user