Archived
- 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
276 lines
8.7 KiB
TypeScript
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;
|
|
}
|