This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/packages/sfeel/src/parser.ts
T
JCAI Main AgentandClaude Opus 5 146e9bbfad M3(1/5): @sfeel —— S-FEEL 子集的解析与求值,前后端同源
- lexer / parser(递归下降)/ evaluate / static-check,四文件纯函数,零 IO 零外部调用
- 子集封顶(ADR-006):比较 · 逻辑 · 算术 · 区间(开闭四态)· 列表与 in ·
  封闭内建函数集(字符串 / 数字 / 日期);未知函数一律拦下
- 单元格谓词(DMN 一格):`-` 任意 / 比较 / 区间 / 逗号析取
- staticCheck 给「画图即校验」用:语法 + 未知函数 + 引用路径清单
- app 侧以它替下 passthroughEvaluator:监听内容条件真求值,求值异常按不命中处置并留痕

32 个 sfeel 测试;全仓 182 个测试通过,门禁五道全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-27 01:21:45 +08:00

276 lines
8.7 KiB
TypeScript

/**
* S-FEEL 子集的语法分析 —— 递归下降,纯函数(编码规范 §8.3)。
*
* 优先级(低 → 高):or < and < 比较/in < 加减 < 乘除 < 一元 < 基元
*/
import type { Node, UnaryTest } from './ast.ts';
import { SfeelSyntaxError, tokenize } from './lexer.ts';
import type { Token } from './lexer.ts';
/** 多词函数名 —— 子集封顶,只认这些(OS 03:无外部调用) */
const MULTIWORD = [
['starts', 'with'],
['ends', 'with'],
['string', 'length'],
['date', 'and', 'time'],
['days', 'and', 'time', 'duration'],
['upper', 'case'],
['lower', 'case'],
] as const;
const KEYWORDS = new Set(['and', 'or', 'not', 'true', 'false', 'null', 'in']);
class Parser {
private index = 0;
constructor(private readonly tokens: readonly Token[]) {}
private peek(offset = 0): Token {
return this.tokens[Math.min(this.index + offset, this.tokens.length - 1)]!;
}
private next(): Token {
const token = this.peek();
this.index += 1;
return token;
}
private eat(text: string): boolean {
if (this.peek().text === text && this.peek().kind !== 'string') {
this.index += 1;
return true;
}
return false;
}
private expect(text: string): Token {
if (!this.eat(text)) throw new SfeelSyntaxError(`期望 ${text},实际 ${this.peek().text || 'EOF'}`, this.peek().pos);
return this.tokens[this.index - 1]!;
}
atEnd(): boolean {
return this.peek().kind === 'eof';
}
parseExpression(): Node {
return this.parseOr();
}
private parseOr(): Node {
let left = this.parseAnd();
while (this.peek().kind === 'name' && this.peek().text === 'or') {
this.next();
left = { kind: 'binary', op: 'or', left, right: this.parseAnd() };
}
return left;
}
private parseAnd(): Node {
let left = this.parseComparison();
while (this.peek().kind === 'name' && this.peek().text === 'and') {
this.next();
left = { kind: 'binary', op: 'and', left, right: this.parseComparison() };
}
return left;
}
private parseComparison(): Node {
const left = this.parseAdditive();
const token = this.peek();
if (token.kind === 'name' && token.text === 'in') {
this.next();
return { kind: 'in', value: left, candidates: this.parseAdditive() };
}
const ops = ['<=', '>=', '!=', '==', '<', '>', '='] as const;
for (const op of ops) {
if (token.kind === 'punct' && token.text === op) {
this.next();
const normalized = op === '==' ? '=' : op;
return { kind: 'binary', op: normalized, left, right: this.parseAdditive() };
}
}
return left;
}
private parseAdditive(): Node {
let left = this.parseMultiplicative();
for (;;) {
const token = this.peek();
if (token.kind === 'punct' && (token.text === '+' || token.text === '-')) {
this.next();
left = { kind: 'binary', op: token.text, left, right: this.parseMultiplicative() };
continue;
}
return left;
}
}
private parseMultiplicative(): Node {
let left = this.parseUnary();
for (;;) {
const token = this.peek();
if (token.kind === 'punct' && (token.text === '*' || token.text === '/')) {
this.next();
left = { kind: 'binary', op: token.text, left, right: this.parseUnary() };
continue;
}
return left;
}
}
private parseUnary(): Node {
const token = this.peek();
if (token.kind === 'punct' && token.text === '-') {
this.next();
return { kind: 'unary', op: '-', operand: this.parseUnary() };
}
if (token.kind === 'name' && token.text === 'not') {
this.next();
this.expect('(');
const operand = this.parseExpression();
this.expect(')');
return { kind: 'unary', op: 'not', operand };
}
return this.parsePrimary();
}
/** 尝试合并多词函数名;不匹配则返回单词 */
private readName(first: Token): string {
for (const words of MULTIWORD) {
if (words[0] !== first.text) continue;
const matches = words.every((word, offset) => (offset === 0 ? true : this.peek(offset - 1).text === word));
if (matches) {
for (let k = 1; k < words.length; k += 1) this.next();
return words.join(' ');
}
}
return first.text;
}
private parsePrimary(): Node {
const token = this.next();
if (token.kind === 'number') return { kind: 'literal', value: token.value as number };
if (token.kind === 'string') return { kind: 'literal', value: token.value as string };
// `(` 兼两职:分组与开区间起点 —— 先解析内部,再看是不是 `..`
if (token.kind === 'punct' && token.text === '(') {
const inner = this.parseExpression();
if (this.peek().kind === 'punct' && this.peek().text === '..') {
this.next();
return this.finishRange(true, inner);
}
this.expect(')');
return inner;
}
// 列表或区间:[a, b] / [1..10]
if (token.kind === 'punct' && token.text === '[') {
return this.parseListOrRange(false);
}
if (token.kind === 'name') {
if (token.text === 'true') return { kind: 'literal', value: true };
if (token.text === 'false') return { kind: 'literal', value: false };
if (token.text === 'null') return { kind: 'literal', value: null };
const name = this.readName(token);
if (this.peek().kind === 'punct' && this.peek().text === '(') {
this.next();
const args: Node[] = [];
if (!(this.peek().kind === 'punct' && this.peek().text === ')')) {
args.push(this.parseExpression());
while (this.eat(',')) args.push(this.parseExpression());
}
this.expect(')');
return { kind: 'call', name, args };
}
if (KEYWORDS.has(name)) throw new SfeelSyntaxError(`关键字 ${name} 不能作为路径`, token.pos);
const segments = [name];
while (this.peek().kind === 'punct' && this.peek().text === '.') {
this.next();
const part = this.next();
if (part.kind !== 'name') throw new SfeelSyntaxError('路径分段必须是名字', part.pos);
segments.push(part.text);
}
return { kind: 'path', segments };
}
throw new SfeelSyntaxError(`意外的记号 ${token.text || 'EOF'}`, token.pos);
}
private parseListOrRange(startOpen: boolean): Node {
// 空列表
if (this.peek().kind === 'punct' && this.peek().text === ']') {
this.next();
return { kind: 'list', items: [] };
}
const first = this.parseExpression();
if (this.peek().kind === 'punct' && this.peek().text === '..') {
this.next();
return this.finishRange(startOpen, first);
}
const items = [first];
while (this.eat(',')) items.push(this.parseExpression());
this.expect(']');
return { kind: 'list', items };
}
/** 区间收尾:`..` 已吃掉,读上界与闭合符 */
private finishRange(startOpen: boolean, start: Node): Node {
const end = this.parseExpression();
const closing = this.next();
if (closing.kind !== 'punct' || (closing.text !== ']' && closing.text !== ')')) {
throw new SfeelSyntaxError('区间必须以 ] 或 ) 收尾', closing.pos);
}
return { kind: 'range', startOpen, endOpen: closing.text === ')', start, end };
}
/** 单元格谓词:`-` / `<10` / `[1..5]` / `"A","B"` */
parseUnaryTest(): UnaryTest {
if (this.peek().kind === 'punct' && this.peek().text === '-' && this.peek(1).kind === 'eof') {
this.next();
return { kind: 'any' };
}
const tests: UnaryTest[] = [this.parseSingleUnaryTest()];
while (this.eat(',')) tests.push(this.parseSingleUnaryTest());
return tests.length === 1 ? tests[0]! : { kind: 'disjunction', tests };
}
private parseSingleUnaryTest(): UnaryTest {
const token = this.peek();
const ops = ['<=', '>=', '!=', '<', '>', '='] as const;
for (const op of ops) {
if (token.kind === 'punct' && token.text === op) {
this.next();
return { kind: 'compare', op, operand: this.parseAdditive() };
}
}
const node = this.parseExpression();
if (node.kind === 'range') return { kind: 'range', node };
return { kind: 'compare', op: '=', operand: node };
}
}
export function parse(source: string): Node {
const parser = new Parser(tokenize(source));
const node = parser.parseExpression();
if (!parser.atEnd()) throw new SfeelSyntaxError('表达式尾部有多余记号', 0);
return node;
}
export function parseUnaryTest(source: string): UnaryTest {
const trimmed = source.trim();
if (trimmed === '' || trimmed === '-') return { kind: 'any' };
const parser = new Parser(tokenize(trimmed));
const test = parser.parseUnaryTest();
if (!parser.atEnd()) throw new SfeelSyntaxError('单元格谓词尾部有多余记号', 0);
return test;
}