chore: import upstream snapshot with attribution
CI / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:09:51 +08:00
commit 0232b4e2bb
3528 changed files with 291404 additions and 0 deletions
@@ -0,0 +1,21 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Container, ContainerModule, interfaces } from 'inversify';
import { ScopeChain, VariableContainerModule } from '../src';
import { MockScopeChain } from './mock-chain';
import { createPlaygroundContainer } from '@flowgram.ai/core';
export function getContainer(customModule?: interfaces.ContainerModuleCallBack): Container {
const container = createPlaygroundContainer() as Container;
container.load(VariableContainerModule);
container.bind(ScopeChain).to(MockScopeChain).inSingletonScope();
if(customModule) {
container.load(new ContainerModule(customModule))
}
return container;
}
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ScopeChain, Scope } from '../src';
/**
* 规则:
* - Global 覆盖所有 Scope,所有 Scope 依赖 Global
* - 存在环路:cycle1 -> cycle2 -> cycle3 -> cycle1
*/
export class MockScopeChain extends ScopeChain {
getDeps(scope: Scope): Scope[] {
const res: Scope[] = [];
if (scope.id === 'global') {
return [];
}
const global = this.variableEngine.getScopeById('global');
if (global) {
res.push(global);
}
// 模拟循环依赖场景
if (String(scope.id).startsWith('cycle')) {
return this.variableEngine
.getAllScopes()
.filter((_scope) => String(_scope.id).startsWith('cycle') && _scope.id !== scope.id);
}
return res;
}
getCovers(scope: Scope): Scope[] {
if (scope.id === 'global') {
return this.variableEngine.getAllScopes().filter(_scope => _scope.id !== 'global');
}
// 模拟循环依赖场景
if (String(scope.id).startsWith('cycle')) {
return this.variableEngine
.getAllScopes()
.filter((_scope) => String(_scope.id).startsWith('cycle') && _scope.id !== scope.id);
}
return [];
}
sortAll(): Scope[] {
return this.variableEngine.getAllScopes();
}
}
@@ -0,0 +1,85 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind, ObjectJSON, VariableDeclarationListJSON } from '../src';
export const simpleVariableList = {
kind: ASTKind.VariableDeclarationList,
declarations: [
{
type: ASTKind.String,
key: 'string',
},
{
type: ASTKind.Boolean,
key: 'boolean',
},
{
// VariableDeclarationList 的 declarations 中可以不用声明 Kind
// kind: ASTKind.VariableDeclaration,
type: ASTKind.Number,
key: 'number',
},
{
kind: ASTKind.VariableDeclaration,
type: ASTKind.Integer,
key: 'integer',
},
{
kind: ASTKind.VariableDeclaration,
type: {
kind: ASTKind.Object,
properties: [
{
key: 'key1',
type: ASTKind.String,
// Object 的 properties 中可以不用声明 Kind
kind: ASTKind.Property,
},
{
key: 'key2',
type: {
kind: ASTKind.Object,
properties: [
{
key: 'key1',
type: ASTKind.Number,
},
],
} as ObjectJSON,
},
{
key: 'key3',
type: {
kind: ASTKind.Array,
itemType: ASTKind.String,
},
},
{
key: 'key4',
type: {
kind: ASTKind.Array,
items: {
kind: ASTKind.Object,
properties: [
{
key: 'key1',
type: ASTKind.Boolean,
},
],
} as ObjectJSON,
},
},
],
} as ObjectJSON,
key: 'object',
},
{
kind: ASTKind.VariableDeclaration,
type: { kind: ASTKind.Map, valueType: ASTKind.Number },
key: 'map',
},
],
} as VariableDeclarationListJSON;
@@ -0,0 +1,97 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`test Key Path Expression V2 > init const test_key_path = source.a 1`] = `
{
"initializer": {
"keyPath": [
"source",
"a",
],
"kind": "KeyPathExpression",
},
"key": "test_key_path",
"kind": "VariableDeclaration",
"order": 0,
"type": {
"kind": "String",
},
}
`;
exports[`test Key Path Expression V2 > subscribe variable changes by expression 1`] = `
[
1,
{
"kind": "Boolean",
},
]
`;
exports[`test Key Path Expression V2 > subscribe variable changes by expression 2`] = `
[
2,
{
"kind": "Number",
},
]
`;
exports[`test Key Path Expression V2 > subscribe variable changes by expression 3`] = `
[
3,
undefined,
]
`;
exports[`test Key Path Expression V2 > subscribe variable changes by expression 4`] = `
[
4,
{
"kind": "Number",
},
]
`;
exports[`test Key Path Expression V2 > subscribe variable changes by expression 5`] = `
[
5,
undefined,
]
`;
exports[`test Key Path Expression V2 > subscribe variable changes by expression 6`] = `
[
6,
{
"kind": "Number",
},
]
`;
exports[`test Key Path Expression V2 > subscribe variable changes by expression 7`] = `
{
"initializer": {
"enumerateFor": {
"keyPath": [
"source",
"b",
],
"kind": "KeyPathExpression",
},
"kind": "EnumerateExpression",
},
"key": "test_key_path",
"kind": "VariableDeclaration",
"order": 0,
"type": {
"kind": "Number",
},
}
`;
exports[`test Key Path Expression V2 > subscribe variable changes by expression 8`] = `
[
7,
undefined,
]
`;
@@ -0,0 +1,170 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`test Basic Variable Declaration > test globalVariableTable variables 1`] = `
[
"string",
"boolean",
"number",
"integer",
"object",
"map",
]
`;
exports[`test Basic Variable Declaration > test remove variable, update variable, add variable by from a new json 1`] = `
[
"object",
"new_integer",
]
`;
exports[`test Basic Variable Declaration > test remove variable, update variable, add variable by from a new json 2`] = `[]`;
exports[`test Basic Variable Declaration > test simple variable declarations 1`] = `
{
"declarations": [
{
"key": "string",
"kind": "VariableDeclaration",
"order": 0,
"type": {
"kind": "String",
},
},
{
"key": "boolean",
"kind": "VariableDeclaration",
"order": 1,
"type": {
"kind": "Boolean",
},
},
{
"key": "number",
"kind": "VariableDeclaration",
"order": 2,
"type": {
"kind": "Number",
},
},
{
"key": "integer",
"kind": "VariableDeclaration",
"order": 3,
"type": {
"kind": "Integer",
},
},
{
"key": "object",
"kind": "VariableDeclaration",
"order": 4,
"type": {
"kind": "Object",
"properties": [
{
"key": "key1",
"kind": "Property",
"type": {
"kind": "String",
},
},
{
"key": "key2",
"kind": "Property",
"type": {
"kind": "Object",
"properties": [
{
"key": "key1",
"kind": "Property",
"type": {
"kind": "Number",
},
},
],
},
},
{
"key": "key3",
"kind": "Property",
"type": {
"kind": "Array",
},
},
{
"key": "key4",
"kind": "Property",
"type": {
"items": {
"kind": "Object",
"properties": [
{
"key": "key1",
"kind": "Property",
"type": {
"kind": "Boolean",
},
},
],
},
"kind": "Array",
},
},
],
},
},
{
"key": "map",
"kind": "VariableDeclaration",
"order": 5,
"type": {
"keyType": {
"kind": "String",
},
"kind": "Map",
"valueType": {
"kind": "Number",
},
},
},
],
"kind": "VariableDeclarationList",
}
`;
exports[`test Basic Variable Declaration > variable declaration subscribe 1`] = `
{
"kind": "Number",
}
`;
exports[`test Basic Variable Declaration > variable declaration subscribe 2`] = `
{
"kind": "Object",
"properties": [
{
"key": "key1",
"kind": "Property",
"type": {
"kind": "String",
},
},
],
}
`;
exports[`test Basic Variable Declaration > variable declaration subscribe 3`] = `
{
"kind": "Object",
"properties": [
{
"key": "key1",
"kind": "Property",
"type": {
"kind": "Number",
},
},
],
}
`;
@@ -0,0 +1,97 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`test Variable With Initializer > init const test_key_path = source.a 1`] = `
{
"initializer": {
"keyPath": [
"source",
"a",
],
"kind": "KeyPathExpression",
},
"key": "test_key_path",
"kind": "VariableDeclaration",
"order": 0,
"type": {
"kind": "String",
},
}
`;
exports[`test Variable With Initializer > subscribe variable changes by expression 1`] = `
[
1,
{
"kind": "Boolean",
},
]
`;
exports[`test Variable With Initializer > subscribe variable changes by expression 2`] = `
[
2,
{
"kind": "Number",
},
]
`;
exports[`test Variable With Initializer > subscribe variable changes by expression 3`] = `
[
3,
undefined,
]
`;
exports[`test Variable With Initializer > subscribe variable changes by expression 4`] = `
[
4,
{
"kind": "Number",
},
]
`;
exports[`test Variable With Initializer > subscribe variable changes by expression 5`] = `
[
5,
undefined,
]
`;
exports[`test Variable With Initializer > subscribe variable changes by expression 6`] = `
[
6,
{
"kind": "Number",
},
]
`;
exports[`test Variable With Initializer > subscribe variable changes by expression 7`] = `
{
"initializer": {
"enumerateFor": {
"keyPath": [
"source",
"b",
],
"kind": "KeyPathExpression",
},
"kind": "EnumerateExpression",
},
"key": "test_key_path",
"kind": "VariableDeclaration",
"order": 0,
"type": {
"kind": "Number",
},
}
`;
exports[`test Variable With Initializer > subscribe variable changes by expression 8`] = `
[
7,
undefined,
]
`;
@@ -0,0 +1,90 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { describe, expect, test } from 'vitest';
import { VariableEngine } from '../../src/variable-engine';
import { ASTNode, postConstructAST } from '../../src/ast';
import { getContainer } from '../../__mocks__/container';
describe('test ast decorators', () => {
const container = getContainer();
const variableEngine: VariableEngine = container.get(VariableEngine);
const testScope = variableEngine.createScope('test');
test('@postConstructAST()', () => {
let postConstructCalled = false;
class PostConstructTest extends ASTNode {
static kind = 'PostConstructTest';
testFlag = false;
@postConstructAST()
init() {
postConstructCalled = true;
this.testFlag = true;
}
fromJSON(json: any): void {
// do nothing
}
toJSON() {
return {
testFlag: this.testFlag,
};
}
}
variableEngine.astRegisters.registerAST(PostConstructTest);
const ast: PostConstructTest = testScope.ast.set('test', {
kind: 'PostConstructTest',
});
const ast2: PostConstructTest = testScope.ast.set('test', {
kind: 'PostConstructTest',
});
expect(postConstructCalled).toBeTruthy();
expect(ast.testFlag).toBeTruthy();
expect(ast2.testFlag).toBeTruthy();
});
test('@postConstructAST() Annotate Only One time', () => {
expect(() => {
class PostConstructTest2 extends ASTNode {
static kind = 'PostConstructTest2';
testFlag1 = false;
testFlag2 = false;
@postConstructAST()
init() {
this.testFlag1 = true;
}
@postConstructAST()
init2() {
this.testFlag2 = true;
}
fromJSON(json: any): void {
// do nothing
}
toJSON() {
return {
testFlag1: this.testFlag1,
testFlag2: this.testFlag2,
};
}
}
variableEngine.astRegisters.registerAST(PostConstructTest2);
}).toThrowError();
});
});
@@ -0,0 +1,338 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { vi, describe, test, expect } from 'vitest';
import { getParentFields } from '../../src/ast/utils/variable-field';
import { ASTKind, VariableEngine, VariableDeclaration, ASTFactory } from '../../src';
import { getContainer } from '../../__mocks__/container';
const {
createVariableDeclaration,
createObject,
createProperty,
createString,
createNumber,
createKeyPathExpression,
createArray,
createEnumerateExpression,
} = ASTFactory;
vi.mock('nanoid', () => {
let mockId = 0;
return {
nanoid: () => 'mocked-id-' + mockId++,
};
});
/**
* 测试通过表达式生成的变量
*/
describe('test Key Path Expression V2', () => {
const container = getContainer();
const variableEngine = container.get(VariableEngine);
const globalScope = variableEngine.createScope('global');
const testScope = variableEngine.createScope('test');
const globalTestVariable = globalScope.ast.set<VariableDeclaration>(
'test',
createVariableDeclaration({
key: 'source',
type: createObject({
properties: [
createProperty({
key: 'a',
type: createString(),
}),
createProperty({
key: 'b',
type: createNumber(),
}),
],
}),
})
);
test('init const test_key_path = source.a', () => {
const variableByKeyPath = testScope.ast.set<VariableDeclaration>(
'variableByKeyPath',
createVariableDeclaration({
key: 'test_key_path',
initializer: createKeyPathExpression({
keyPath: ['source', 'a'],
}),
})
)!;
expect(variableByKeyPath.initializer?.kind).toEqual(ASTKind.KeyPathExpression);
expect(variableByKeyPath.initializer?.refs.map((_ref) => _ref?.key)).toEqual(['a']);
// variableByKeyPath 的类型重新建立了实例,其父节点为当前节点
expect(getParentFields(variableByKeyPath.type).map((_field) => _field?.key)).toEqual([
'test_key_path',
]);
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.String });
expect(variableByKeyPath.toJSON()).toMatchSnapshot();
});
test('subscribe variable changes by expression', () => {
// const variableByKeyPath = source.a;
const variableByKeyPath = testScope.output.getVariableByKey('test_key_path')!;
let typeChangeTimes = 0;
variableByKeyPath.onTypeChange((type) => {
typeChangeTimes++;
expect([typeChangeTimes, type?.toJSON()]).toMatchSnapshot();
});
// source.a 发生变化时,则响应更新变量
// source.a -> boolean
globalTestVariable.getByKeyPath(['a'])?.updateType(ASTKind.Boolean);
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.Boolean });
expect(typeChangeTimes).toBe(1);
// 改成引用 source.b,响应更新变量
// const variableByKeyPath = source.b;
variableByKeyPath.updateInitializer({
kind: ASTKind.KeyPathExpression,
keyPath: ['source', 'b'],
});
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.Number });
expect(typeChangeTimes).toBe(2);
// source.b 被删除,变量类型变为空
globalTestVariable.type.fromJSON({
properties: [
{
key: 'a',
type: ASTKind.String,
},
],
});
expect(variableByKeyPath.type).toBeUndefined();
expect(typeChangeTimes).toBe(3);
// source.b 加回来,变量类型变回来且触发表达式更新
globalTestVariable.type.fromJSON(
createObject({
properties: [
createProperty({ key: 'a', type: createString() }),
createProperty({ key: 'b', type: createNumber() }),
],
})
);
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.Number });
expect(typeChangeTimes).toBe(4);
// 改成 EnumerateExpression
variableByKeyPath.updateInitializer(
createEnumerateExpression({
enumerateFor: createKeyPathExpression({
keyPath: ['source', 'b'],
}),
})
);
expect(variableByKeyPath.type).toBeUndefined();
expect(variableByKeyPath.initializer?.refs).toEqual([]);
variableByKeyPath.initializer?.refreshRefs();
expect(variableByKeyPath.initializer?.refs).toEqual([]);
expect(typeChangeTimes).toBe(5);
// 数组下钻,类型也会更新
globalTestVariable.type.fromJSON(
createObject({
properties: [
createProperty({
key: 'b',
type: createArray({
items: createNumber(),
}),
}),
],
})
);
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.Number });
expect(typeChangeTimes).toBe(6);
expect(variableByKeyPath.toJSON()).toMatchSnapshot();
// 原作用域删除,显示为空类型
globalScope.dispose();
expect(variableByKeyPath.scope.depScopes.map((_scope) => _scope.id)).toEqual([]);
expect(variableByKeyPath.type).toBeUndefined();
expect(typeChangeTimes).toBe(7);
});
const cycle1 = variableEngine.createScope('cycle1');
const cycle2 = variableEngine.createScope('cycle2');
const cycle3 = variableEngine.createScope('cycle3');
test('cycle scope with normal refs', () => {
const cycle1Var = cycle1.ast.set<VariableDeclaration>(
'var',
createVariableDeclaration({
key: 'cycle1_var',
type: createObject({
properties: [
createProperty({
key: 'a',
type: createArray({
items: createString(),
}),
}),
],
}),
})
);
const cycle2Var = cycle2.ast.set<VariableDeclaration>(
'var',
createVariableDeclaration({
key: 'cycle2_var',
initializer: createEnumerateExpression({
enumerateFor: createKeyPathExpression({
keyPath: ['cycle1_var', 'a'],
}),
}),
})
);
const cycle3Var = cycle3.ast.set<VariableDeclaration>(
'var',
createVariableDeclaration({
key: 'cycle3_var',
initializer: createKeyPathExpression({
keyPath: ['cycle2_var'],
}),
})
);
expect(cycle1Var.type.toJSON()).toEqual({
kind: ASTKind.Object,
properties: [
{
kind: ASTKind.Property,
key: 'a',
type: {
kind: ASTKind.Array,
items: { kind: ASTKind.String },
},
},
],
});
expect(cycle2Var.type.toJSON()).toEqual({
kind: ASTKind.String,
});
expect(cycle3Var.type.toJSON()).toEqual({
kind: ASTKind.String,
});
});
test('cycle scope with child ref', () => {
const cycle1Var = cycle1.ast.get('var')! as VariableDeclaration;
const cycle2Var = cycle2.ast.get('var')! as VariableDeclaration;
const cycle3Var = cycle3.ast.get('var')! as VariableDeclaration;
cycle1Var.fromJSON({
type: createObject({
properties: [
createProperty({
key: 'a',
type: createArray({
items: createString(),
}),
}),
createProperty({
key: 'b',
initializer: createKeyPathExpression({
keyPath: ['cycle3_var'],
}),
}),
],
}),
});
expect(cycle1Var.type.toJSON()).toEqual({
kind: ASTKind.Object,
properties: [
{
kind: ASTKind.Property,
key: 'a',
type: {
kind: ASTKind.Array,
items: { kind: ASTKind.String },
},
},
{
kind: ASTKind.Property,
key: 'b',
initializer: {
kind: ASTKind.KeyPathExpression,
keyPath: ['cycle3_var'],
},
type: {
kind: ASTKind.String,
},
},
],
});
expect(cycle2Var.type.toJSON()).toEqual({
kind: ASTKind.String,
});
expect(cycle3Var.type.toJSON()).toEqual({
kind: ASTKind.String,
});
});
test('cycle scope with cycle ref', () => {
const cycle1Var = cycle1.ast.get('var')! as VariableDeclaration;
const cycle2Var = cycle2.ast.get('var')! as VariableDeclaration;
const cycle3Var = cycle3.ast.get('var')! as VariableDeclaration;
cycle1Var.fromJSON({
type: createObject({
properties: [
createProperty({
key: 'a',
initializer: createKeyPathExpression({
keyPath: ['cycle3_var'],
}),
}),
createProperty({
key: 'b',
initializer: createKeyPathExpression({
keyPath: ['cycle3_var'],
}),
}),
],
}),
});
expect(cycle1Var.type.toJSON()).toEqual({
kind: ASTKind.Object,
properties: [
{
kind: ASTKind.Property,
key: 'a',
initializer: {
kind: ASTKind.KeyPathExpression,
keyPath: ['cycle3_var'],
},
// 发生循环引用时,type 清空
},
{
kind: ASTKind.Property,
key: 'b',
initializer: {
kind: ASTKind.KeyPathExpression,
keyPath: ['cycle3_var'],
},
// 发生循环引用时,type 清空
},
],
});
expect(cycle2Var.type?.toJSON()).toBeUndefined();
expect(cycle3Var.type?.toJSON()).toBeUndefined();
});
});
@@ -0,0 +1,208 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { vi, describe, test, expect } from 'vitest';
import {
ASTKind,
ASTMatch,
ObjectType,
NumberType,
VariableEngine,
VariableDeclaration,
} from '../../src';
import { simpleVariableList } from '../../__mocks__/variables';
import { getContainer } from '../../__mocks__/container';
vi.mock('nanoid', () => {
let mockId = 0;
return {
nanoid: () => 'mocked-id-' + mockId++,
};
});
/**
* 测试基本的变量声明场景
*/
describe('test Basic Variable Declaration', () => {
const container = getContainer();
const variableEngine = container.get(VariableEngine);
const globalVariableTable = variableEngine.globalVariableTable;
const testScope = variableEngine.createScope('test');
test('test simple variable declarations', () => {
const simpleCase = testScope.ast.set('simple case', simpleVariableList);
expect(simpleCase?.toJSON()).toMatchSnapshot();
});
test('test globalVariableTable variables', () => {
expect(globalVariableTable.variables.map((_v) => _v.key)).toMatchSnapshot();
});
test('test get variable by key path', () => {
const undefinedCases = [
['object', 'key2', 'unExisted'],
['unExisted'],
['string', 'drilldownString'],
['object', 'key1', 'drilldownString'],
['object', 'key4', 'unExistedKeyInArray', 'key1'],
];
const availableCases: [string[], string][] = [
[['object', 'key2', 'key1'], ASTKind.Number],
[['object', 'key4', '0', 'key1'], ASTKind.Boolean],
];
availableCases.forEach(([_case, _resType]) => {
expect(globalVariableTable.getByKeyPath(_case)?.type.kind).toEqual(_resType);
});
undefinedCases.forEach((_case) => {
expect(globalVariableTable.getByKeyPath(_case)).toBeUndefined();
});
});
test('test remove variable, update variable, add variable by from a new json', () => {
const previousVariable1 = globalVariableTable.getByKeyPath(['object', 'key2', 'key1'])!;
const previousVariable2 = globalVariableTable.getByKeyPath(['integer'])!;
const previousVariable3 = globalVariableTable.getByKeyPath(['object', 'key4'])!;
testScope.ast.set('simple case', {
kind: ASTKind.VariableDeclarationList,
declarations: [
{
kind: ASTKind.VariableDeclaration,
type: ASTKind.Integer,
key: 'new_integer',
},
{
kind: ASTKind.VariableDeclaration,
key: 'object',
type: {
kind: ASTKind.Object,
properties: [
{
key: 'key2',
type: {
kind: ASTKind.Object,
},
},
{
key: 'key4',
type: ASTKind.String,
},
{
key: 'key5',
type: ASTKind.Array,
},
],
},
},
],
});
expect(previousVariable1.disposed).toBe(true);
expect(previousVariable2.disposed).toBe(true);
expect(previousVariable3.disposed).toBe(false);
expect(previousVariable3.version).toBe(1); // 更新次数为一次
expect(testScope.ast.version).toBe(2); // 调用了两次 fromJSON,因此更新了两次
expect(globalVariableTable.variables.map((_v) => _v.key)).toMatchSnapshot();
expect(globalVariableTable.version).toBe(2 + 1); // 调用了两次 fromJSON + Object 变量的下钻发生变化,因此 version 是 2 + 1
expect(testScope.available.variables.map((_v) => _v.key)).toMatchSnapshot();
});
test('remove variables', () => {
let isOutputChanged = false;
let isAnyVariableChanged = false;
testScope.output.onDataChange(() => {
isOutputChanged = true;
});
testScope.output.onAnyVariableChange(() => {
isAnyVariableChanged = true;
});
// 删除所有变量
testScope.ast.set('simple case', {
kind: ASTKind.VariableDeclarationList,
declarations: [],
});
expect(isOutputChanged).toBeTruthy();
expect(isAnyVariableChanged).toBeFalsy();
});
test('variable declaration subscribe', () => {
const declaration: VariableDeclaration = testScope.ast.set('subscribeTest', {
kind: ASTKind.VariableDeclaration,
type: ASTKind.String,
ui: { label: 'test Label' },
})!;
let declarationChangeTimes = 0;
let typeChangeTimes = 0;
let outputChangeTimes = 0;
let anyVariableChangeTimes = 0;
testScope.output.onDataChange(() => {
outputChangeTimes++;
});
testScope.output.onAnyVariableChange(() => {
anyVariableChangeTimes++;
});
declaration.subscribe(() => {
declarationChangeTimes++;
});
declaration.onTypeChange((type) => {
expect(type?.toJSON()).toMatchSnapshot();
typeChangeTimes++;
});
declaration.fromJSON({
type: ASTKind.String,
meta: { label: 'test New Label' },
});
expect(declarationChangeTimes).toBe(1);
expect(anyVariableChangeTimes).toBe(1);
expect(typeChangeTimes).toBe(0);
expect(outputChangeTimes).toBe(0);
expect(declaration.meta.label).toEqual('test New Label');
declaration.fromJSON({
type: ASTKind.Number,
meta: { label: 'test Label' },
});
expect(ASTMatch.is(declaration.type, NumberType)).toBeTruthy();
expect(declarationChangeTimes).toBe(2);
expect(anyVariableChangeTimes).toBe(2);
expect(typeChangeTimes).toBe(1);
expect(outputChangeTimes).toBe(0);
expect(declaration.meta.label).toEqual('test Label');
declaration.fromJSON({
type: {
kind: ASTKind.Object,
properties: [
{
key: 'key1',
type: ASTKind.String,
},
],
},
meta: { label: 'test Label' },
});
expect(ASTMatch.is(declaration.type, ObjectType)).toBeTruthy();
expect(declarationChangeTimes).toBe(3);
expect(anyVariableChangeTimes).toBe(3);
expect(typeChangeTimes).toBe(2);
expect(outputChangeTimes).toBe(0);
// 变量下钻字段变换类型,变量也会更新
const key1Variable = (declaration.type as ObjectType).getByKeyPath(['key1']);
key1Variable?.updateType(ASTKind.Number);
expect(declarationChangeTimes).toBe(4);
expect(anyVariableChangeTimes).toBe(4);
expect(typeChangeTimes).toBe(3);
expect(outputChangeTimes).toBe(0);
});
});
@@ -0,0 +1,79 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { vi, describe, test, expect } from 'vitest';
import {
ASTMatch,
ObjectType,
NumberType,
VariableEngine,
StringType,
VariableDeclarationList,
BooleanType,
IntegerType,
MapType,
ArrayType,
} from '../../src';
import { simpleVariableList } from '../../__mocks__/variables';
import { getContainer } from '../../__mocks__/container';
vi.mock('nanoid', () => {
let mockId = 0;
return {
nanoid: () => 'mocked-id-' + mockId++,
};
});
/**
* 测试基本的变量声明场景
*/
describe('test Basic Variable Declaration', () => {
const container = getContainer();
const variableEngine = container.get(VariableEngine);
const testScope = variableEngine.createScope('test');
test('test simple variable match', () => {
const simpleCase = testScope.ast.set('simple case', simpleVariableList);
if (!ASTMatch.isVariableDeclarationList(simpleCase)) {
throw new Error('simpleCase is not a VariableDeclarationList');
}
expect(ASTMatch.isVariableDeclarationList(simpleCase)).toBeTruthy();
expect(ASTMatch.is(simpleCase, VariableDeclarationList)).toBeTruthy();
const stringDeclaration = simpleCase.declarations[0];
const booleanDeclaration = simpleCase.declarations[1];
const numberDeclaration = simpleCase.declarations[2];
const integerDeclaration = simpleCase.declarations[3];
const objectDeclaration = simpleCase.declarations[4];
const mapDeclaration = simpleCase.declarations[5];
const arrayProperty = testScope.output.globalVariableTable.getByKeyPath(['object', 'key4']);
expect(ASTMatch.isString(stringDeclaration.type)).toBeTruthy();
expect(ASTMatch.is(stringDeclaration.type, StringType)).toBeTruthy();
expect(ASTMatch.isBoolean(booleanDeclaration.type)).toBeTruthy();
expect(ASTMatch.is(booleanDeclaration.type, BooleanType)).toBeTruthy();
expect(ASTMatch.isNumber(numberDeclaration.type)).toBeTruthy();
expect(ASTMatch.is(numberDeclaration.type, NumberType)).toBeTruthy();
expect(ASTMatch.isInteger(integerDeclaration.type)).toBeTruthy();
expect(ASTMatch.is(integerDeclaration.type, IntegerType)).toBeTruthy();
expect(ASTMatch.isObject(objectDeclaration.type)).toBeTruthy();
expect(ASTMatch.is(objectDeclaration.type, ObjectType)).toBeTruthy();
expect(ASTMatch.isMap(mapDeclaration.type)).toBeTruthy();
expect(ASTMatch.is(mapDeclaration.type, MapType)).toBeTruthy();
if (!ASTMatch.isProperty(arrayProperty)) {
throw new Error('arrayProperty is not a Property');
}
expect(ASTMatch.isArray(arrayProperty.type)).toBeTruthy();
expect(ASTMatch.is(arrayProperty.type, ArrayType)).toBeTruthy();
});
});
@@ -0,0 +1,82 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { describe, expect, test } from 'vitest';
import { VariableEngine } from '../../src/variable-engine';
import { ASTFactory } from '../../src/ast';
import { simpleVariableList } from '../../__mocks__/variables';
import { getContainer } from '../../__mocks__/container';
describe('Test Variable ThrowError', () => {
const container = getContainer();
const variableEngine = container.get(VariableEngine);
const testScope = variableEngine.createScope('test');
testScope.ast.set('simple case', simpleVariableList);
test('throw error when call getByKeyPath in base type', () => {
const stringVariable = testScope.output.getVariableByKey('string');
expect(() => stringVariable?.type.getByKeyPath(['throw'])).toThrowError();
});
test('not throw error when variable is disposed for twice', () => {
const stringVariable = testScope.output.getVariableByKey('string');
expect(stringVariable?.disposed).toBeFalsy();
// 删除所有变量
testScope.ast.set(
'simple case',
ASTFactory.createVariableDeclarationList({ declarations: [] }),
);
expect(stringVariable?.disposed).toBeTruthy();
stringVariable?.dispose();
expect(stringVariable?.disposed).toBeTruthy();
});
test('not throw error when scope is disposed and fire its events', () => {
const toDisposeScope = variableEngine.createScope('toDisposeTest');
let eventCalledTimes = 0;
toDisposeScope.event.on('test', () => eventCalledTimes++);
toDisposeScope.refreshDeps();
toDisposeScope.event.dispatch({ type: 'test' });
expect(toDisposeScope.disposed).toBeFalsy();
expect(eventCalledTimes).toBe(1);
toDisposeScope.dispose();
expect(toDisposeScope.disposed).toBeTruthy();
toDisposeScope.event.dispatch({ type: 'test' });
toDisposeScope.refreshDeps();
expect(eventCalledTimes).toBe(1);
});
test('not throw error when ast is disposed and fire its events', () => {
const toDisposeAST = testScope.ast.set(
'dispose case',
ASTFactory.createVariableDeclaration({
key: 'toDispose',
type: ASTFactory.createString(),
}),
);
let changeTimes = 0;
toDisposeAST.subscribe(() => changeTimes++);
toDisposeAST.fromJSON({
key: 'toDispose',
type: ASTFactory.createNumber(),
});
expect(changeTimes).toBe(1);
expect(toDisposeAST.disposed).toBeFalsy();
testScope.ast.remove('dispose case');
expect(toDisposeAST.disposed).toBeTruthy();
toDisposeAST.fireChange();
expect(changeTimes).toBe(1);
});
});
@@ -0,0 +1,195 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { describe, expect, test } from 'vitest';
import { VariableEngine } from '../../src/variable-engine';
import { ASTFactory, ASTKind, CustomType, VariableDeclaration } from '../../src/ast';
import { getContainer } from '../../__mocks__/container';
const {
createObject,
createNumber,
createInteger,
createBoolean,
createString,
createArray,
createCustomType,
createMap,
createProperty,
createUnion,
create,
} = ASTFactory;
describe('Test Variable Type Equal', () => {
const container = getContainer();
const variableEngine = container.get(VariableEngine);
const testScope = variableEngine.createScope('test');
const testObject1 = createObject({
properties: [
createProperty({ key: 'a', type: createString() }),
createProperty({ key: 'b', type: createNumber() }),
createProperty({ key: 'c', type: createBoolean() }),
createProperty({ key: 'd', type: createObject({}) }),
createProperty({ key: 'e', type: createArray({}) }),
createProperty({ key: 'f', type: createMap({}) }),
createProperty({ key: 'g', type: createCustomType({ typeName: 'Custom' }) }),
],
});
const testObject2 = createObject({
properties: [
createProperty({ key: 'a', type: createString() }),
createProperty({ key: 'b', type: createInteger() }),
createProperty({ key: 'c', type: createBoolean() }),
],
});
const testObject3 = createObject({
properties: [
createProperty({ key: 'a', type: createString() }),
createProperty({ key: 'b', type: testObject2 }),
createProperty({
key: 'c',
type: createArray({
items: testObject2,
}),
}),
createProperty({
key: 'd',
type: createArray({ items: createString() }),
}),
createProperty({
key: 'e',
type: createMap({
valueType: createNumber(),
}),
}),
createProperty({
key: 'f',
type: createMap({
valueType: createString(),
}),
}),
],
});
const variable1: VariableDeclaration = testScope.ast.set('variable1', {
kind: ASTKind.VariableDeclaration,
key: 'variable1',
type: testObject1,
});
const variable2: VariableDeclaration = testScope.ast.set('variable2', {
kind: ASTKind.VariableDeclaration,
key: 'variable2',
type: testObject2,
});
const variable3: VariableDeclaration = testScope.ast.set('variable3', {
kind: ASTKind.VariableDeclaration,
key: 'variable3',
type: testObject3,
});
test('Test Simple Equal', () => {
expect(variable1.type.isTypeEqual(testObject1)).toBeTruthy();
expect(variable2.type.isTypeEqual(testObject2)).toBeTruthy();
expect(variable3.type.isTypeEqual(testObject3)).toBeTruthy();
expect(variable1.type.isTypeEqual(testObject2)).toBeFalsy();
expect(variable2.type.isTypeEqual(testObject3)).toBeFalsy();
});
test('Test Union Type Equal', () => {
expect(
variable1.type.isTypeEqual(
createUnion({
types: [testObject1, testObject2, testObject3],
})
)
).toBeTruthy();
expect(
variable2.type.isTypeEqual(
createUnion({
types: [testObject1, testObject2, testObject3],
})
)
).toBeTruthy();
expect(
variable3.type.isTypeEqual(
createUnion({
types: [testObject1, testObject2, testObject3],
})
)
).toBeTruthy();
expect(variable1.type.isTypeEqual({ kind: ASTKind.Union })).toBeFalsy();
});
test('Test Union Type Equal In Child Properties', () => {
const UnionBasicTypes = createUnion({
types: [ASTKind.String, ASTKind.Number, ASTKind.Boolean],
});
expect(
variable2.type.isTypeEqual({
kind: ASTKind.Object,
properties: [
{
key: 'a',
type: UnionBasicTypes,
},
{
key: 'b',
type: UnionBasicTypes,
},
{
key: 'c',
type: UnionBasicTypes,
},
],
})
);
});
test('Test Weak Compare', () => {
expect(variable1.type.isTypeEqual({ kind: ASTKind.Object, weak: true })).toBeTruthy();
expect(variable2.type.isTypeEqual({ kind: ASTKind.Object, weak: true })).toBeTruthy();
expect(variable3.type.isTypeEqual({ kind: ASTKind.Object, weak: true })).toBeTruthy();
expect(
variable3.getByKeyPath(['c'])?.type.isTypeEqual({ kind: ASTKind.Array, weak: true })
).toBeTruthy();
expect(
variable3.getByKeyPath(['d'])?.type.isTypeEqual({ kind: ASTKind.Array, weak: true })
).toBeTruthy();
expect(
variable3.getByKeyPath(['e'])?.type.isTypeEqual({ kind: ASTKind.Map, weak: true })
).toBeTruthy();
expect(
variable3.getByKeyPath(['f'])?.type.isTypeEqual({ kind: ASTKind.Map, weak: true })
).toBeTruthy();
});
test('CustomType Equal', () => {
const customType1 = createCustomType({ typeName: 'Custom' });
const customType2 = createCustomType({ typeName: 'Custom2' });
const customType3 = create(CustomType, { typeName: 'Custom' });
const unionCustomTypes = createUnion({
types: [customType1, customType2],
});
const customTypeProperty = variable1.getByKeyPath(['g'])?.type;
expect(customTypeProperty?.isTypeEqual(customType1)).toBeTruthy();
expect(customTypeProperty?.isTypeEqual(customType2)).toBeFalsy();
expect(customTypeProperty?.isTypeEqual(customType3)).toBeTruthy();
expect(customTypeProperty?.isTypeEqual(unionCustomTypes)).toBeTruthy();
});
});
@@ -0,0 +1,168 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { vi, describe, test, expect } from 'vitest';
import { ASTKind, VariableEngine, VariableDeclaration, ASTFactory } from '../../src';
import { getContainer } from '../../__mocks__/container';
const {
createVariableDeclaration,
createObject,
createProperty,
createString,
createNumber,
createKeyPathExpression,
createArray,
createEnumerateExpression,
} = ASTFactory;
vi.mock('nanoid', () => {
let mockId = 0;
return {
nanoid: () => 'mocked-id-' + mockId++,
};
});
/**
* 测试通过表达式生成的变量
*/
describe('test Variable With Initializer', () => {
const container = getContainer();
const variableEngine = container.get(VariableEngine);
const globalScope = variableEngine.createScope('global');
const testScope = variableEngine.createScope('test');
const globalTestVariable = globalScope.ast.set<VariableDeclaration>(
'test',
createVariableDeclaration({
key: 'source',
type: createObject({
properties: [
createProperty({
key: 'a',
type: createString(),
}),
createProperty({
key: 'b',
type: createNumber(),
}),
],
}),
}),
);
test('test depScopes', () => {
expect(testScope.depScopes.map(_scope => _scope.id)).toEqual(['global']);
});
test('init const test_key_path = source.a', () => {
const variableByKeyPath = testScope.ast.set<VariableDeclaration>(
'variableByKeyPath',
createVariableDeclaration({
key: 'test_key_path',
initializer: createKeyPathExpression({
keyPath: ['source', 'a'],
}),
}),
)!;
expect(variableByKeyPath.initializer?.kind).toEqual(ASTKind.KeyPathExpression);
expect(variableByKeyPath.initializer?.refs.map(_ref => _ref?.key)).toEqual(['a']);
expect(variableByKeyPath.initializer?.parentFields.map(_field => _field?.key)).toEqual([
'test_key_path',
]);
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.String });
expect(variableByKeyPath.toJSON()).toMatchSnapshot();
});
test('subscribe variable changes by expression', () => {
// const variableByKeyPath = source.a;
const variableByKeyPath = testScope.output.getVariableByKey('test_key_path')!;
let typeChangeTimes = 0;
variableByKeyPath.onTypeChange(type => {
typeChangeTimes++;
expect([typeChangeTimes, type?.toJSON()]).toMatchSnapshot();
});
// source.a 发生变化时,则响应更新变量
// source.a -> boolean
globalTestVariable.getByKeyPath(['a'])?.updateType(ASTKind.Boolean);
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.Boolean });
expect(typeChangeTimes).toBe(1);
// 改成引用 source.b,响应更新变量
// const variableByKeyPath = source.b;
variableByKeyPath.updateInitializer({
kind: ASTKind.KeyPathExpression,
keyPath: ['source', 'b'],
});
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.Number });
expect(typeChangeTimes).toBe(2);
// source.b 被删除,变量类型变为空
globalTestVariable.type.fromJSON({
properties: [
{
key: 'a',
type: ASTKind.String,
},
],
});
expect(variableByKeyPath.type).toBeUndefined();
expect(typeChangeTimes).toBe(3);
// source.b 加回来,变量类型变回来且触发表达式更新
globalTestVariable.type.fromJSON(
createObject({
properties: [
createProperty({ key: 'a', type: createString() }),
createProperty({ key: 'b', type: createNumber() }),
],
}),
);
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.Number });
expect(typeChangeTimes).toBe(4);
// 改成 EnumerateExpression
variableByKeyPath.updateInitializer(
createEnumerateExpression({
enumerateFor: createKeyPathExpression({
keyPath: ['source', 'b'],
}),
}),
);
expect(variableByKeyPath.type).toBeUndefined();
expect(variableByKeyPath.initializer?.refs).toEqual([]);
variableByKeyPath.initializer?.refreshRefs();
expect(variableByKeyPath.initializer?.refs).toEqual([]);
expect(typeChangeTimes).toBe(5);
// 数组下钻,类型也会更新
globalTestVariable.type.fromJSON(
createObject({
properties: [
createProperty({
key: 'b',
type: createArray({
items: createNumber(),
}),
}),
],
}),
);
expect(variableByKeyPath.type.toJSON()).toEqual({ kind: ASTKind.Number });
expect(typeChangeTimes).toBe(6);
expect(variableByKeyPath.toJSON()).toMatchSnapshot();
// 原作用域删除,显示为空类型
globalScope.dispose();
expect(variableByKeyPath.scope.depScopes.map(_scope => _scope.id)).toEqual([]);
expect(variableByKeyPath.type).toBeUndefined();
expect(typeChangeTimes).toBe(7);
});
});
@@ -0,0 +1,179 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { describe, expect, test } from 'vitest';
import { isEqual } from 'lodash-es';
import { Container, injectable } from 'inversify';
import { Emitter } from '@flowgram.ai/utils';
import { CreateASTParams } from '../../src/ast/types';
import {
ASTFactory,
BaseExpression,
BaseType,
VariableDeclaration,
VariableEngine,
injectToAST,
} from '../../src';
import { getContainer } from '../../__mocks__/container';
interface PyExpressionJSON {
content: string;
uri: string;
}
const delay = (timeout: number) => new Promise((resolve) => setTimeout(resolve, timeout));
describe('Case Run Down: Python Expression In Blockwise', () => {
@injectable()
class PythonService {
// 模拟 Blockwise 表达式后端存储推导的信息
uriToType: Record<string, any> = {
'blockwise://expression/a': { kind: 'String' },
'blockwise://expression/b': { kind: 'Number' },
};
// 模拟 Blockwise 单表达式推导函数
async infer(json: PyExpressionJSON) {
return Promise.resolve(this.uriToType[json.uri]);
}
// 模拟表达式后端重新推导表达式
batchInferEmitter = new Emitter<void>();
onBatchInfer = this.batchInferEmitter.event;
}
class PythonExpression extends BaseExpression<PyExpressionJSON> {
@injectToAST(PythonService) declare service: PythonService;
static kind: string = 'BlockwisePythonExpression';
_uri?: string;
_content?: string;
// Blockwise 通过 schema 变更时输出的方式来进行类型推导
getRefFields() {
return [];
}
returnType: BaseType<any> | undefined;
_prevType: any;
// Blockwise 中 表达式通过 uri 来进行索引定位
fromJSON(json: PyExpressionJSON): void {
if (json.uri !== this._uri || json.content !== this._content) {
this.service.infer(json).then((res) => {
this._prevType = res;
this.updateChildNodeByKey('returnType', res);
});
this._uri = json.uri;
this._content = json.content;
}
}
toJSON(): PyExpressionJSON {
return {
content: this._content!,
uri: this._uri!,
};
}
constructor(params: CreateASTParams) {
super(params);
this.toDispose.push(
// 监听后端触发批量校验
this.service.onBatchInfer(() => {
const nextType = this.service.uriToType[this._uri!];
if (!isEqual(nextType, this._prevType)) {
this.updateChildNodeByKey('returnType', nextType);
this._prevType = nextType;
}
})
);
}
}
const createBlockwisePythonExpression = (json: PyExpressionJSON) => ({
kind: 'BlockwisePythonExpression',
...json,
});
const container: Container = getContainer((bind) => {
bind(PythonService).toSelf().inSingletonScope();
});
const variableEngine: VariableEngine = container.get(VariableEngine);
const pythonService: PythonService = container.get(PythonService);
variableEngine.astRegisters.registerAST(PythonExpression, () => ({
pythonService,
}));
const testScope = variableEngine.createScope('test');
test('1. Infer When Expression Changed', async () => {
const variable1: VariableDeclaration = testScope.ast.set(
'variable1',
ASTFactory.createVariableDeclaration({
key: 'variable1',
initializer: createBlockwisePythonExpression({
uri: 'blockwise://expression/a',
content: 'a + b',
}),
})
);
await delay(0);
expect(variable1.type?.kind).toEqual('String');
expect(variable1.version).toEqual(1);
// 更新表达式
pythonService.uriToType['blockwise://expression/a'] = { kind: 'Number' };
variable1.fromJSON({
initializer: createBlockwisePythonExpression({
uri: 'blockwise://expression/a',
content: 'a + b + c',
}),
});
await delay(0);
expect(variable1.type?.kind).toEqual('Number');
expect(variable1.version).toEqual(2);
});
test('2. Infer When Global Update Triggered', async () => {
const variable2: VariableDeclaration = testScope.ast.set(
'variable2',
ASTFactory.createVariableDeclaration({
key: 'variable2',
initializer: createBlockwisePythonExpression({
uri: 'blockwise://expression/b',
content: 'a + b',
}),
})
);
await delay(0);
expect(variable2.type?.kind).toEqual('Number');
expect(variable2.version).toEqual(1);
// 1. 表达式类型没有变化
pythonService.uriToType['blockwise://expression/b'] = { kind: 'Number' };
pythonService.batchInferEmitter.fire();
await delay(0);
expect(variable2.type?.kind).toEqual('Number');
expect(variable2.version).toEqual(1);
// 2. 表达式类型发生了变化
pythonService.uriToType['blockwise://expression/b'] = { kind: 'Boolean' };
pythonService.batchInferEmitter.fire();
await delay(0);
expect(variable2.type?.kind).toEqual('Boolean');
expect(variable2.version).toEqual(2);
});
});
@@ -0,0 +1,146 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { vi, describe, test, beforeEach, expect } from 'vitest';
import { cloneDeep } from 'lodash-es';
import { ASTNode, ASTNodeFlags, VariableEngine, VariableFieldKeyRenameService } from '../../src';
import { simpleVariableList } from '../../__mocks__/variables';
import { getContainer } from '../../__mocks__/container';
vi.mock('nanoid', () => {
let mockId = 0;
return {
nanoid: () => 'mocked-id-' + mockId++,
};
});
function getFieldKeys(node: ASTNode): string[] {
let _curr = node?.parent;
const parentKeys: string[] = [];
while (_curr) {
if (_curr.flags & ASTNodeFlags.VariableField) {
parentKeys.unshift(_curr.key);
}
_curr = _curr.parent;
}
return [...parentKeys, node.key];
}
/**
* 测试变量 Key Rename 的场景
*/
describe('test Listen Variable Key Rename', () => {
const container = getContainer();
const variableEngine = container.get(VariableEngine);
const renameService = container.get(VariableFieldKeyRenameService);
const testScope = variableEngine.createScope('test');
let renameInfo: { before: string[]; after: string[] } | null = null;
let disposeList: string[][] = [];
renameService.onRename((_rename) => {
renameInfo = {
before: getFieldKeys(_rename.before),
after: getFieldKeys(_rename.after),
};
});
renameService.onDisposeInList((_field) => {
disposeList.push(getFieldKeys(_field));
});
beforeEach(() => {
testScope.ast.set('test', simpleVariableList);
renameInfo = null;
disposeList = [];
});
const produceNextJSON = (producer: (json: any) => void) => {
const next = cloneDeep(simpleVariableList);
producer(next);
return next;
};
test.each([
// 更改一个字段
[
produceNextJSON((json) => {
json.declarations[0].key = 'string1111';
}),
['string'],
['string1111'],
[],
],
// 更改一个下钻字段
[
produceNextJSON((json) => {
json.declarations[4].type.properties[0].key = 'changedKey';
}),
['object', 'key1'],
['object', 'changedKey'],
[],
],
// 更改字段和他的类型
[
produceNextJSON((json) => {
json.declarations[0].key = 'string1111';
json.declarations[0].type = 'Number';
}),
null,
null,
[['string']],
],
// 更改多个下钻字段
[
produceNextJSON((json) => {
json.declarations[4].type.properties[0].key = 'changedKey';
json.declarations[4].type.properties[1].key = 'changedKey222';
}),
null,
null,
[
['object', 'key1'],
['object', 'key2'],
],
],
// 更改多个字段
[
produceNextJSON((json) => {
json.declarations[0].key = 'string1111';
json.declarations[1].key = 'boolean1111';
}),
null,
null,
[['string'], ['boolean']],
],
// 删除变量
[
produceNextJSON((json) => {
json.declarations = json.declarations.slice(1);
}),
null,
null,
[['string']],
],
// 添加变量
[
produceNextJSON((json) => {
json.declarations.push({ kind: 'String', key: 'newKey' });
}),
null,
null,
[],
],
])('test variable change', (_json, _before, _after, _disposeList) => {
testScope.ast.set('test', _json);
expect(renameInfo?.before || null).toEqual(_before);
expect(renameInfo?.after || null).toEqual(_after);
expect(disposeList).toEqual(_disposeList);
});
});
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { vi, describe, test, expect } from 'vitest';
import { VariableEngine } from '../../src';
import { simpleVariableList } from '../../__mocks__/variables';
import { getContainer } from '../../__mocks__/container';
vi.mock('nanoid', () => {
let mockId = 0;
return {
nanoid: () => 'mocked-id-' + mockId++,
};
});
/**
* 测试基本的变量声明场景
*/
describe('test Variable Engine', () => {
const container = getContainer();
const variableEngine = container.get(VariableEngine);
const globalVariableTable = variableEngine.globalVariableTable;
const testScope = variableEngine.createScope('test');
const simpleCase = testScope.ast.set('simple case', simpleVariableList);
test('test variable Engine Dispose', () => {
// unbind All will trigger @preDestroy
container.unbindAll();
expect(variableEngine.chain.disposed).toBeTruthy();
expect(testScope.disposed).toBeTruthy();
expect(simpleCase.disposed).toBeTruthy();
expect(globalVariableTable.variableKeys.length).toBe(0);
});
});
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
module.exports = defineFlatConfig({
preset: 'web',
packageRoot: __dirname,
ignorePatterns: ['**/tests__'],
});
@@ -0,0 +1,65 @@
{
"name": "@flowgram.ai/variable-core",
"version": "0.1.8",
"description": "variable engine based on scope",
"keywords": [
"flow",
"variable",
"scope",
"engine"
],
"homepage": "https://flowgram.ai/",
"repository": "https://github.com/bytedance/flowgram.ai",
"license": "MIT",
"exports": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/index.js"
},
"main": "./dist/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "npm run build:fast -- --dts-resolve",
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
"build:watch": "npm run build:fast -- --dts-resolve",
"clean": "rimraf dist",
"test": "vitest run",
"test:cov": "vitest run --coverage",
"ts-check": "tsc --noEmit",
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
},
"dependencies": {
"@flowgram.ai/core": "workspace:*",
"@flowgram.ai/utils": "workspace:*",
"fast-equals": "^2.0.0",
"inversify": "^6.0.1",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.9",
"reflect-metadata": "~0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@flowgram.ai/eslint-config": "workspace:*",
"@flowgram.ai/ts-config": "workspace:*",
"@types/lodash-es": "^4.17.12",
"@types/react": "^18",
"@types/react-dom": "^18",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.0.0",
"tsup": "^8.0.1",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
@@ -0,0 +1,374 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
BehaviorSubject,
animationFrameScheduler,
debounceTime,
distinctUntilChanged,
map,
skip,
tap,
} from 'rxjs';
import { nanoid } from 'nanoid';
import { isNil, omitBy } from 'lodash-es';
import { shallowEqual } from 'fast-equals';
import { Disposable, DisposableCollection } from '@flowgram.ai/utils';
import { subsToDisposable } from '../utils/toDisposable';
import { updateChildNodeHelper } from './utils/helpers';
import { type Scope } from '../scope';
import {
type ASTNodeJSON,
type ObserverOrNext,
type ASTKindType,
type CreateASTParams,
type Identifier,
SubscribeConfig,
GlobalEventActionType,
DisposeASTAction,
UpdateASTAction,
} from './types';
import { ASTNodeFlags } from './flags';
export interface ASTNodeRegistry<JSON extends ASTNodeJSON = any> {
kind: string;
new (params: CreateASTParams, injectOpts: any): ASTNode<JSON>;
}
/**
* An `ASTNode` represents a fundamental unit of variable information within the system's Abstract Syntax Tree.
* It can model various constructs, for example:
* - **Declarations**: `const a = 1`
* - **Expressions**: `a.b.c`
* - **Types**: `number`, `string`, `boolean`
*
* Here is some characteristic of ASTNode:
* - **Tree-like Structure**: ASTNodes can be nested to form a tree, representing complex variable structures.
* - **Extendable**: New features can be added by extending the base ASTNode class.
* - **Reactive**: Changes in an ASTNode's value trigger events, enabling reactive programming patterns.
* - **Serializable**: ASTNodes can be converted to and from a JSON format (ASTNodeJSON) for storage or transmission.
*/
export abstract class ASTNode<JSON extends ASTNodeJSON = any> implements Disposable {
/**
* @deprecated
* Get the injected options for the ASTNode.
*
* Please use `@injectToAst(XXXService) declare xxxService: XXXService` to achieve external dependency injection.
*/
public readonly opts?: any;
/**
* The unique identifier of the ASTNode, which is **immutable**.
* - Immutable: Once assigned, the key cannot be changed.
* - Automatically generated if not specified, and cannot be changed as well.
* - If a new key needs to be generated, the current ASTNode should be destroyed and a new ASTNode should be generated.
*/
public readonly key: Identifier;
/**
* The kind of the ASTNode.
*/
static readonly kind: ASTKindType;
/**
* Node flags, used to record some flag information.
*/
public readonly flags: number = ASTNodeFlags.None;
/**
* The scope in which the ASTNode is located.
*/
public readonly scope: Scope;
/**
* The parent ASTNode.
*/
public readonly parent: ASTNode | undefined;
/**
* The version number of the ASTNode, which increments by 1 each time `fireChange` is called.
*/
protected _version: number = 0;
/**
* Update lock.
* - When set to `true`, `fireChange` will not trigger any events.
* - This is useful when multiple updates are needed, and you want to avoid multiple triggers.
*/
public changeLocked = false;
/**
* Parameters related to batch updates.
*/
private _batch: {
batching: boolean;
hasChangesInBatch: boolean;
} = {
batching: false,
hasChangesInBatch: false,
};
/**
* AST node change Observable events, implemented based on RxJS.
* - Emits the current ASTNode value upon subscription.
* - Emits a new value whenever `fireChange` is called.
*/
public readonly value$: BehaviorSubject<ASTNode> = new BehaviorSubject<ASTNode>(this as ASTNode);
/**
* Child ASTNodes.
*/
protected _children = new Set<ASTNode>();
/**
* List of disposal handlers for the ASTNode.
*/
public readonly toDispose: DisposableCollection = new DisposableCollection(
Disposable.create(() => {
// When a child element is deleted, the parent element triggers an update.
this.parent?.fireChange();
this.children.forEach((child) => child.dispose());
})
);
/**
* Callback triggered upon disposal.
*/
onDispose = this.toDispose.onDispose;
/**
* Constructor.
* @param createParams Necessary parameters for creating an ASTNode.
* @param injectOptions Dependency injection for various modules.
*/
constructor({ key, parent, scope }: CreateASTParams, opts?: any) {
this.scope = scope;
this.parent = parent;
this.opts = opts;
// Initialize the key value. If a key is passed in, use it; otherwise, generate a random one using nanoid.
this.key = key || nanoid();
// All `fireChange` calls within the subsequent `fromJSON` will be merged into one.
this.fromJSON = this.withBatchUpdate(this.fromJSON.bind(this));
// Add the kind field to the JSON output.
const rawToJSON = this.toJSON?.bind(this);
this.toJSON = () =>
omitBy(
{
// always include kind
kind: this.kind,
...(rawToJSON?.() || {}),
},
// remove undefined fields
isNil
) as JSON;
}
/**
* The type of the ASTNode.
*/
get kind(): string {
if (!(this.constructor as any).kind) {
throw new Error(`ASTNode Registry need a kind: ${this.constructor.name}`);
}
return (this.constructor as any).kind;
}
/**
* Parses AST JSON data.
* @param json AST JSON data.
*/
abstract fromJSON(json: JSON): void;
/**
* Gets all child ASTNodes of the current ASTNode.
*/
get children(): ASTNode[] {
return Array.from(this._children);
}
/**
* Serializes the current ASTNode to ASTNodeJSON.
* @returns
*/
abstract toJSON(): JSON;
/**
* Creates a child ASTNode.
* @param json The AST JSON of the child ASTNode.
* @returns
*/
protected createChildNode<ChildNode extends ASTNode = ASTNode>(json: ASTNodeJSON): ChildNode {
const astRegisters = this.scope.variableEngine.astRegisters;
const child = astRegisters.createAST(json, {
parent: this,
scope: this.scope,
}) as ChildNode;
// Add to the _children set.
this._children.add(child);
child.toDispose.push(
Disposable.create(() => {
this._children.delete(child);
})
);
return child;
}
/**
* Updates a child ASTNode, quickly implementing the consumption logic for child ASTNode updates.
* @param keyInThis The specified key on the current object.
*/
protected updateChildNodeByKey(keyInThis: keyof this, nextJSON?: ASTNodeJSON) {
this.withBatchUpdate(updateChildNodeHelper).call(this, {
getChildNode: () => this[keyInThis] as ASTNode,
updateChildNode: (_node) => ((this as any)[keyInThis] = _node),
removeChildNode: () => ((this as any)[keyInThis] = undefined),
nextJSON,
});
}
/**
* Batch updates the ASTNode, merging all `fireChange` calls within the batch function into one.
* @param updater The batch function.
* @returns
*/
protected withBatchUpdate<ParamTypes extends any[], ReturnType>(
updater: (...args: ParamTypes) => ReturnType
) {
return (...args: ParamTypes) => {
// Nested batchUpdate can only take effect once.
if (this._batch.batching) {
return updater.call(this, ...args);
}
this._batch.hasChangesInBatch = false;
this._batch.batching = true;
const res = updater.call(this, ...args);
this._batch.batching = false;
if (this._batch.hasChangesInBatch) {
this.fireChange();
}
this._batch.hasChangesInBatch = false;
return res;
};
}
/**
* Triggers an update for the current node.
*/
fireChange(): void {
if (this.changeLocked || this.disposed) {
return;
}
if (this._batch.batching) {
this._batch.hasChangesInBatch = true;
return;
}
this._version++;
this.value$.next(this);
this.dispatchGlobalEvent<UpdateASTAction>({ type: 'UpdateAST' });
this.parent?.fireChange();
}
/**
* The version value of the ASTNode.
* - You can used to check whether ASTNode are updated.
*/
get version(): number {
return this._version;
}
/**
* The unique hash value of the ASTNode.
* - It will update when the ASTNode is updated.
* - You can used to check two ASTNode are equal.
*/
get hash(): string {
return `${this._version}${this.kind}${this.key}`;
}
/**
* Listens for changes to the ASTNode.
* @param observer The listener callback.
* @param selector Listens for specified data.
* @returns
*/
subscribe<Data = this>(
observer: ObserverOrNext<Data>,
{ selector, debounceAnimation, triggerOnInit }: SubscribeConfig<this, Data> = {}
): Disposable {
return subsToDisposable(
this.value$
.pipe(
map(() => (selector ? selector(this) : (this as any))),
distinctUntilChanged(
(a, b) => shallowEqual(a, b),
(value) => {
if (value instanceof ASTNode) {
// If the value is an ASTNode, compare its hash.
return value.hash;
}
return value;
}
),
// By default, skip the first trigger of BehaviorSubject.
triggerOnInit ? tap(() => null) : skip(1),
// All updates within each animationFrame are merged into one.
debounceAnimation ? debounceTime(0, animationFrameScheduler) : tap(() => null)
)
.subscribe(observer)
);
}
/**
* Dispatches a global event for the current ASTNode.
* @param event The global event.
*/
dispatchGlobalEvent<ActionType extends GlobalEventActionType = GlobalEventActionType>(
event: Omit<ActionType, 'ast'>
) {
this.scope.event.dispatch({
...event,
ast: this,
});
}
/**
* Disposes the ASTNode.
*/
dispose(): void {
// Prevent multiple disposals.
if (this.toDispose.disposed) {
return;
}
this.toDispose.dispose();
this.dispatchGlobalEvent<DisposeASTAction>({ type: 'DisposeAST' });
// When the complete event is emitted, ensure that the current ASTNode is in a disposed state.
this.value$.complete();
this.value$.unsubscribe();
}
get disposed(): boolean {
return this.toDispose.disposed;
}
/**
* Extended information of the ASTNode.
*/
[key: string]: unknown;
}
@@ -0,0 +1,129 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { omit } from 'lodash-es';
import { injectable } from 'inversify';
import { POST_CONSTRUCT_AST_SYMBOL } from './utils/inversify';
import { ASTKindType, ASTNodeJSON, CreateASTParams, NewASTAction } from './types';
import { ArrayType } from './type/array';
import {
BooleanType,
CustomType,
IntegerType,
MapType,
NumberType,
ObjectType,
StringType,
} from './type';
import { EnumerateExpression, KeyPathExpression, WrapArrayExpression } from './expression';
import { Property, VariableDeclaration, VariableDeclarationList } from './declaration';
import { DataNode, MapNode } from './common';
import { ASTNode, ASTNodeRegistry } from './ast-node';
type DataInjector = () => Record<string, any>;
/**
* Register the AST node to the engine.
*/
@injectable()
export class ASTRegisters {
/**
* @deprecated Please use `@injectToAst(XXXService) declare xxxService: XXXService` to achieve external dependency injection.
*/
protected injectors: Map<ASTKindType, DataInjector> = new Map();
protected astMap: Map<ASTKindType, ASTNodeRegistry> = new Map();
/**
* Core AST node registration.
*/
constructor() {
this.registerAST(StringType);
this.registerAST(NumberType);
this.registerAST(BooleanType);
this.registerAST(IntegerType);
this.registerAST(ObjectType);
this.registerAST(ArrayType);
this.registerAST(MapType);
this.registerAST(CustomType);
this.registerAST(Property);
this.registerAST(VariableDeclaration);
this.registerAST(VariableDeclarationList);
this.registerAST(KeyPathExpression);
this.registerAST(EnumerateExpression);
this.registerAST(WrapArrayExpression);
this.registerAST(MapNode);
this.registerAST(DataNode);
}
/**
* Creates an AST node.
* @param param Creation parameters.
* @returns
*/
createAST<ReturnNode extends ASTNode = ASTNode>(
json: ASTNodeJSON,
{ parent, scope }: CreateASTParams
): ReturnNode {
const Registry = this.astMap.get(json.kind!);
if (!Registry) {
throw Error(`ASTKind: ${String(json.kind)} can not find its ASTNode Registry`);
}
const injector = this.injectors.get(json.kind!);
const node = new Registry(
{
key: json.key,
scope,
parent,
},
injector?.() || {}
) as ReturnNode;
// Do not trigger fireChange during initial creation.
node.changeLocked = true;
node.fromJSON(omit(json, ['key', 'kind']));
node.changeLocked = false;
node.dispatchGlobalEvent<NewASTAction>({ type: 'NewAST' });
if (Reflect.hasMetadata(POST_CONSTRUCT_AST_SYMBOL, node)) {
const postConstructKey = Reflect.getMetadata(POST_CONSTRUCT_AST_SYMBOL, node);
(node[postConstructKey] as () => void)?.();
}
return node;
}
/**
* Gets the node Registry by AST node type.
* @param kind
* @returns
*/
getASTRegistryByKind(kind: ASTKindType) {
return this.astMap.get(kind);
}
/**
* Registers an AST node.
* @param ASTNode
*/
registerAST(
ASTNode: ASTNodeRegistry,
/**
* @deprecated Please use `@injectToAst(XXXService) declare xxxService: XXXService` to achieve external dependency injection.
*/
injector?: DataInjector
) {
this.astMap.set(ASTNode.kind, ASTNode);
if (injector) {
this.injectors.set(ASTNode.kind, injector);
}
}
}
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { shallowEqual } from 'fast-equals';
import { ASTKind, ASTNodeJSON } from '../types';
import { ASTNode } from '../ast-node';
/**
* Represents a general data node with no child nodes.
*/
export class DataNode<Data = any> extends ASTNode {
static kind: string = ASTKind.DataNode;
protected _data: Data;
/**
* The data of the node.
*/
get data(): Data {
return this._data;
}
/**
* Deserializes the `DataNodeJSON` to the `DataNode`.
* @param json The `DataNodeJSON` to deserialize.
*/
fromJSON(json: Data): void {
const { kind, ...restData } = json as ASTNodeJSON;
if (!shallowEqual(restData, this._data)) {
this._data = restData as unknown as Data;
this.fireChange();
}
}
/**
* Serialize the `DataNode` to `DataNodeJSON`.
* @returns The JSON representation of `DataNode`.
*/
toJSON() {
return {
kind: ASTKind.DataNode,
...this._data,
};
}
/**
* Partially update the data of the node.
* @param nextData The data to be updated.
*/
partialUpdate(nextData: Data) {
if (!shallowEqual(nextData, this._data)) {
this._data = {
...this._data,
...nextData,
};
this.fireChange();
}
}
}
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { DataNode } from './data-node';
export { ListNode, type ListNodeJSON } from './list-node';
export { MapNode, type MapNodeJSON } from './map-node';
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind, ASTNodeJSON } from '../types';
import { ASTNode } from '../ast-node';
/**
* ASTNodeJSON representation of `ListNode`
*/
export interface ListNodeJSON {
/**
* The list of nodes.
*/
list: ASTNodeJSON[];
}
/**
* Represents a list of nodes.
*/
export class ListNode extends ASTNode<ListNodeJSON> {
static kind: string = ASTKind.ListNode;
protected _list: ASTNode[];
/**
* The list of nodes.
*/
get list(): ASTNode[] {
return this._list;
}
/**
* Deserializes the `ListNodeJSON` to the `ListNode`.
* @param json The `ListNodeJSON` to deserialize.
*/
fromJSON({ list }: ListNodeJSON): void {
// Children that exceed the length need to be destroyed.
this._list.slice(list.length).forEach((_item) => {
_item.dispose();
this.fireChange();
});
// Processing of remaining children.
this._list = list.map((_item, idx) => {
const prevItem = this._list[idx];
if (prevItem.kind !== _item.kind) {
prevItem.dispose();
this.fireChange();
return this.createChildNode(_item);
}
prevItem.fromJSON(_item);
return prevItem;
});
}
/**
* Serialize the `ListNode` to `ListNodeJSON`.
* @returns The JSON representation of `ListNode`.
*/
toJSON() {
return {
kind: ASTKind.ListNode,
list: this._list.map((item) => item.toJSON()),
};
}
}
@@ -0,0 +1,89 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { updateChildNodeHelper } from '../utils/helpers';
import { ASTKind, ASTNodeJSON } from '../types';
import { ASTNode } from '../ast-node';
/**
* ASTNodeJSON representation of `MapNode`
*/
export interface MapNodeJSON {
/**
* The map of nodes.
*/
map: [string, ASTNodeJSON][];
}
/**
* Represents a map of nodes.
*/
export class MapNode extends ASTNode<MapNodeJSON> {
static kind: string = ASTKind.MapNode;
protected map: Map<string, ASTNode> = new Map<string, ASTNode>();
/**
* Deserializes the `MapNodeJSON` to the `MapNode`.
* @param json The `MapNodeJSON` to deserialize.
*/
fromJSON({ map }: MapNodeJSON): void {
const removedKeys = new Set(this.map.keys());
for (const [key, item] of map || []) {
removedKeys.delete(key);
this.set(key, item);
}
for (const removeKey of Array.from(removedKeys)) {
this.remove(removeKey);
}
}
/**
* Serialize the `MapNode` to `MapNodeJSON`.
* @returns The JSON representation of `MapNode`.
*/
toJSON() {
return {
kind: ASTKind.MapNode,
map: Array.from(this.map.entries()),
};
}
/**
* Set a node in the map.
* @param key The key of the node.
* @param nextJSON The JSON representation of the node.
* @returns The node instance.
*/
set<Node extends ASTNode = ASTNode>(key: string, nextJSON: ASTNodeJSON): Node {
return this.withBatchUpdate(updateChildNodeHelper).call(this, {
getChildNode: () => this.get(key),
removeChildNode: () => this.map.delete(key),
updateChildNode: (nextNode) => this.map.set(key, nextNode),
nextJSON,
}) as Node;
}
/**
* Remove a node from the map.
* @param key The key of the node.
*/
remove(key: string) {
this.get(key)?.dispose();
this.map.delete(key);
this.fireChange();
}
/**
* Get a node from the map.
* @param key The key of the node.
* @returns The node instance if found, otherwise `undefined`.
*/
get<Node extends ASTNode = ASTNode>(key: string): Node | undefined {
return this.map.get(key) as Node | undefined;
}
}
@@ -0,0 +1,184 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { shallowEqual } from 'fast-equals';
import { getParentFields } from '../utils/variable-field';
import { ASTNodeJSON, ASTNodeJSONOrKind, Identifier } from '../types';
import { type BaseType } from '../type';
import { ASTNodeFlags } from '../flags';
import { type BaseExpression } from '../expression';
import { ASTNode } from '../ast-node';
/**
* ASTNodeJSON representation of `BaseVariableField`
*/
export interface BaseVariableFieldJSON<VariableMeta = any> extends ASTNodeJSON {
/**
* key of the variable field
* - For `VariableDeclaration`, the key should be global unique.
* - For `Property`, the key is the property name.
*/
key: Identifier;
/**
* type of the variable field, similar to js code:
* `const v: string`
*/
type?: ASTNodeJSONOrKind;
/**
* initializer of the variable field, similar to js code:
* `const v = 'hello'`
*
* with initializer, the type of field will be inferred from the initializer.
*/
initializer?: ASTNodeJSON;
/**
* meta data of the variable field, you cans store information like `title`, `icon`, etc.
*/
meta?: VariableMeta;
}
/**
* Variable Field abstract class, which is the base class for `VariableDeclaration` and `Property`
*
* - `VariableDeclaration` is used to declare a variable in a block scope.
* - `Property` is used to declare a property in an object.
*/
export abstract class BaseVariableField<VariableMeta = any> extends ASTNode<
BaseVariableFieldJSON<VariableMeta>
> {
public flags: ASTNodeFlags = ASTNodeFlags.VariableField;
protected _type?: BaseType;
protected _meta: VariableMeta = {} as any;
protected _initializer?: BaseExpression;
/**
* Parent variable fields, sorted from closest to farthest
*/
get parentFields(): BaseVariableField[] {
return getParentFields(this);
}
/**
* KeyPath of the variable field, sorted from farthest to closest
*/
get keyPath(): string[] {
return [...this.parentFields.reverse().map((_field) => _field.key), this.key];
}
/**
* Metadata of the variable field, you cans store information like `title`, `icon`, etc.
*/
get meta(): VariableMeta {
return this._meta;
}
/**
* Type of the variable field, similar to js code:
* `const v: string`
*/
get type(): BaseType {
return (this._initializer?.returnType || this._type)!;
}
/**
* Initializer of the variable field, similar to js code:
* `const v = 'hello'`
*
* with initializer, the type of field will be inferred from the initializer.
*/
get initializer(): BaseExpression | undefined {
return this._initializer;
}
/**
* The global unique hash of the field, and will be changed when the field is updated.
*/
get hash(): string {
return `[${this._version}]${this.keyPath.join('.')}`;
}
/**
* Deserialize the `BaseVariableFieldJSON` to the `BaseVariableField`.
* @param json ASTJSON representation of `BaseVariableField`
*/
fromJSON({ type, initializer, meta }: Omit<BaseVariableFieldJSON<VariableMeta>, 'key'>): void {
// 类型变化
this.updateType(type);
// 表达式更新
this.updateInitializer(initializer);
// Extra 更新
this.updateMeta(meta!);
}
/**
* Update the type of the variable field
* @param type type ASTJSON representation of Type
*/
updateType(type: BaseVariableFieldJSON['type']) {
const nextTypeJson = typeof type === 'string' ? { kind: type } : type;
this.updateChildNodeByKey('_type', nextTypeJson);
}
/**
* Update the initializer of the variable field
* @param nextInitializer initializer ASTJSON representation of Expression
*/
updateInitializer(nextInitializer?: BaseVariableFieldJSON['initializer']) {
this.updateChildNodeByKey('_initializer', nextInitializer);
}
/**
* Update the meta data of the variable field
* @param nextMeta meta data of the variable field
*/
updateMeta(nextMeta: VariableMeta) {
if (!shallowEqual(nextMeta, this._meta)) {
this._meta = nextMeta;
this.fireChange();
}
}
/**
* Get the variable field by keyPath, similar to js code:
* `v.a.b`
* @param keyPath
* @returns
*/
getByKeyPath(keyPath: string[]): BaseVariableField | undefined {
if (this.type?.flags & ASTNodeFlags.DrilldownType) {
return this.type.getByKeyPath(keyPath) as BaseVariableField | undefined;
}
return undefined;
}
/**
* Subscribe to type change of the variable field
* @param observer
* @returns
*/
onTypeChange(observer: (type: ASTNode | undefined) => void) {
return this.subscribe(observer, { selector: (curr) => curr.type });
}
/**
* Serialize the variable field to JSON
* @returns ASTNodeJSON representation of `BaseVariableField`
*/
toJSON(): BaseVariableFieldJSON<VariableMeta> {
return {
key: this.key,
type: this.type?.toJSON(),
initializer: this.initializer?.toJSON(),
meta: this._meta,
};
}
}
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { VariableDeclaration, type VariableDeclarationJSON } from './variable-declaration';
export {
VariableDeclarationList,
type VariableDeclarationListJSON,
type VariableDeclarationListChangeAction,
} from './variable-declaration-list';
export { type PropertyJSON, Property } from './property';
export { BaseVariableField } from './base-variable-field';
@@ -0,0 +1,19 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind } from '../types';
import { BaseVariableField, BaseVariableFieldJSON } from './base-variable-field';
/**
* ASTNodeJSON representation of the `Property`.
*/
export type PropertyJSON<VariableMeta = any> = BaseVariableFieldJSON<VariableMeta>;
/**
* `Property` is a variable field that represents a property of a `ObjectType`.
*/
export class Property<VariableMeta = any> extends BaseVariableField<VariableMeta> {
static kind: string = ASTKind.Property;
}
@@ -0,0 +1,112 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind } from '../types';
import { GlobalEventActionType } from '../types';
import { ASTNode } from '../ast-node';
import { type VariableDeclarationJSON, VariableDeclaration } from './variable-declaration';
export interface VariableDeclarationListJSON<VariableMeta = any> {
/**
* `declarations` must be of type `VariableDeclaration`, so the business can omit the `kind` field.
*/
declarations?: VariableDeclarationJSON<VariableMeta>[];
/**
* The starting order number for variables.
*/
startOrder?: number;
}
export type VariableDeclarationListChangeAction = GlobalEventActionType<
'VariableListChange',
{
prev: VariableDeclaration[];
next: VariableDeclaration[];
},
VariableDeclarationList
>;
export class VariableDeclarationList extends ASTNode<VariableDeclarationListJSON> {
static kind: string = ASTKind.VariableDeclarationList;
/**
* Map of variable declarations, keyed by variable name.
*/
declarationTable: Map<string, VariableDeclaration> = new Map();
/**
* Variable declarations, sorted by `order`.
*/
declarations: VariableDeclaration[];
/**
* Deserialize the `VariableDeclarationListJSON` to the `VariableDeclarationList`.
* - VariableDeclarationListChangeAction will be dispatched after deserialization.
*
* @param declarations Variable declarations.
* @param startOrder The starting order number for variables. Default is 0.
*/
fromJSON({ declarations, startOrder }: VariableDeclarationListJSON): void {
const removedKeys = new Set(this.declarationTable.keys());
const prev = [...(this.declarations || [])];
// Iterate over the new properties.
this.declarations = (declarations || []).map(
(declaration: VariableDeclarationJSON, idx: number) => {
const order = (startOrder || 0) + idx;
// If the key is not set, reuse the previous key.
const declarationKey = declaration.key || this.declarations?.[idx]?.key;
const existDeclaration = this.declarationTable.get(declarationKey);
if (declarationKey) {
removedKeys.delete(declarationKey);
}
if (existDeclaration) {
existDeclaration.fromJSON({ order, ...declaration });
return existDeclaration;
} else {
const newDeclaration = this.createChildNode({
order,
...declaration,
kind: ASTKind.VariableDeclaration,
}) as VariableDeclaration;
this.fireChange();
this.declarationTable.set(newDeclaration.key, newDeclaration);
return newDeclaration;
}
}
);
// Delete variables that no longer exist.
removedKeys.forEach((key) => {
const declaration = this.declarationTable.get(key);
declaration?.dispose();
this.declarationTable.delete(key);
});
this.dispatchGlobalEvent<VariableDeclarationListChangeAction>({
type: 'VariableListChange',
payload: {
prev,
next: [...this.declarations],
},
});
}
/**
* Serialize the `VariableDeclarationList` to the `VariableDeclarationListJSON`.
* @returns ASTJSON representation of `VariableDeclarationList`
*/
toJSON() {
return {
kind: ASTKind.VariableDeclarationList,
declarations: this.declarations.map((_declaration) => _declaration.toJSON()),
};
}
}
@@ -0,0 +1,78 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind, GlobalEventActionType, type CreateASTParams } from '../types';
import { BaseVariableField, BaseVariableFieldJSON } from './base-variable-field';
/**
* ASTNodeJSON representation of the `VariableDeclaration`.
*/
export type VariableDeclarationJSON<VariableMeta = any> = BaseVariableFieldJSON<VariableMeta> & {
/**
* Variable sorting order, which is used to sort variables in `scope.outputs.variables`
*/
order?: number;
};
/**
* Action type for re-sorting variable declarations.
*/
export type ReSortVariableDeclarationsAction = GlobalEventActionType<'ReSortVariableDeclarations'>;
/**
* `VariableDeclaration` is a variable field that represents a variable declaration.
*/
export class VariableDeclaration<VariableMeta = any> extends BaseVariableField<VariableMeta> {
static kind: string = ASTKind.VariableDeclaration;
protected _order: number = 0;
/**
* Variable sorting order, which is used to sort variables in `scope.outputs.variables`
*/
get order(): number {
return this._order;
}
constructor(params: CreateASTParams) {
super(params);
}
/**
* Deserialize the `VariableDeclarationJSON` to the `VariableDeclaration`.
*/
fromJSON({ order, ...rest }: Omit<VariableDeclarationJSON<VariableMeta>, 'key'>): void {
// Update order.
this.updateOrder(order);
// Update other information.
super.fromJSON(rest as BaseVariableFieldJSON<VariableMeta>);
}
/**
* Update the sorting order of the variable declaration.
* @param order Variable sorting order. Default is 0.
*/
updateOrder(order: number = 0): void {
if (order !== this._order) {
this._order = order;
this.dispatchGlobalEvent<ReSortVariableDeclarationsAction>({
type: 'ReSortVariableDeclarations',
});
this.fireChange();
}
}
/**
* Serialize the `VariableDeclaration` to `VariableDeclarationJSON`.
* @returns The JSON representation of `VariableDeclaration`.
*/
toJSON(): VariableDeclarationJSON<VariableMeta> {
return {
...super.toJSON(),
order: this.order,
};
}
}
@@ -0,0 +1,117 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
type Observable,
distinctUntilChanged,
map,
switchMap,
combineLatest,
of,
Subject,
share,
} from 'rxjs';
import { shallowEqual } from 'fast-equals';
import { getParentFields } from '../utils/variable-field';
import { ASTNodeJSON, type CreateASTParams } from '../types';
import { type BaseType } from '../type';
import { ASTNodeFlags } from '../flags';
import { type BaseVariableField } from '../declaration';
import { ASTNode } from '../ast-node';
import { subsToDisposable } from '../../utils/toDisposable';
import { IVariableTable } from '../../scope/types';
type ExpressionRefs = (BaseVariableField | undefined)[];
/**
* Base class for all expressions.
*
* All other expressions should extend this class.
*/
export abstract class BaseExpression<JSON extends ASTNodeJSON = any> extends ASTNode<JSON> {
public flags: ASTNodeFlags = ASTNodeFlags.Expression;
/**
* Get the global variable table, which is used to access referenced variables.
*/
get globalVariableTable(): IVariableTable {
return this.scope.variableEngine.globalVariableTable;
}
/**
* Parent variable fields, sorted from closest to farthest.
*/
get parentFields(): BaseVariableField[] {
return getParentFields(this);
}
/**
* Get the variable fields referenced by the expression.
*
* This method should be implemented by subclasses.
* @returns An array of referenced variable fields.
*/
abstract getRefFields(): ExpressionRefs;
/**
* The return type of the expression.
*/
abstract returnType: BaseType | undefined;
/**
* The variable fields referenced by the expression.
*/
protected _refs: ExpressionRefs = [];
/**
* The variable fields referenced by the expression.
*/
get refs(): ExpressionRefs {
return this._refs;
}
protected refreshRefs$: Subject<void> = new Subject();
/**
* Refresh the variable references.
*/
refreshRefs() {
this.refreshRefs$.next();
}
/**
* An observable that emits the referenced variable fields when they change.
*/
refs$: Observable<ExpressionRefs> = this.refreshRefs$.pipe(
map(() => this.getRefFields()),
distinctUntilChanged<ExpressionRefs>(shallowEqual),
switchMap((refs) =>
!refs?.length
? of([])
: combineLatest(
refs.map((ref) =>
ref
? (ref.value$ as unknown as Observable<BaseVariableField | undefined>)
: of(undefined)
)
)
),
share()
);
constructor(params: CreateASTParams, opts?: any) {
super(params, opts);
this.toDispose.push(
subsToDisposable(
this.refs$.subscribe((_refs: ExpressionRefs) => {
this._refs = _refs;
this.fireChange();
})
)
);
}
}
@@ -0,0 +1,77 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind, ASTNodeJSON } from '../types';
import { ArrayType } from '../type/array';
import { BaseType } from '../type';
import { BaseExpression } from './base-expression';
/**
* ASTNodeJSON representation of `EnumerateExpression`
*/
export interface EnumerateExpressionJSON {
/**
* The expression to be enumerated.
*/
enumerateFor: ASTNodeJSON;
}
/**
* Represents an enumeration expression, which iterates over a list and returns the type of the enumerated variable.
*/
export class EnumerateExpression extends BaseExpression<EnumerateExpressionJSON> {
static kind: string = ASTKind.EnumerateExpression;
protected _enumerateFor: BaseExpression | undefined;
/**
* The expression to be enumerated.
*/
get enumerateFor() {
return this._enumerateFor;
}
/**
* The return type of the expression.
*/
get returnType(): BaseType | undefined {
// The return value of the enumerated expression.
const childReturnType = this.enumerateFor?.returnType;
if (childReturnType?.kind === ASTKind.Array) {
// Get the item type of the array.
return (childReturnType as ArrayType).items;
}
return undefined;
}
/**
* Get the variable fields referenced by the expression.
* @returns An empty array, as this expression does not reference any variables.
*/
getRefFields(): [] {
return [];
}
/**
* Deserializes the `EnumerateExpressionJSON` to the `EnumerateExpression`.
* @param json The `EnumerateExpressionJSON` to deserialize.
*/
fromJSON({ enumerateFor: expression }: EnumerateExpressionJSON): void {
this.updateChildNodeByKey('_enumerateFor', expression);
}
/**
* Serialize the `EnumerateExpression` to `EnumerateExpressionJSON`.
* @returns The JSON representation of `EnumerateExpression`.
*/
toJSON() {
return {
kind: ASTKind.EnumerateExpression,
enumerateFor: this.enumerateFor?.toJSON(),
};
}
}
@@ -0,0 +1,10 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { BaseExpression } from './base-expression';
export { EnumerateExpression, type EnumerateExpressionJSON } from './enumerate-expression';
export { KeyPathExpression, type KeyPathExpressionJSON } from './keypath-expression';
export { LegacyKeyPathExpression } from './legacy-keypath-expression';
export { WrapArrayExpression, type WrapArrayExpressionJSON } from './wrap-array-expression';
@@ -0,0 +1,157 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { distinctUntilChanged } from 'rxjs';
import { shallowEqual } from 'fast-equals';
import { checkRefCycle } from '../utils/expression';
import { ASTNodeJSON, ASTKind, CreateASTParams } from '../types';
import { BaseType } from '../type';
import { type BaseVariableField } from '../declaration';
import { subsToDisposable } from '../../utils/toDisposable';
import { BaseExpression } from './base-expression';
/**
* ASTNodeJSON representation of `KeyPathExpression`
*/
export interface KeyPathExpressionJSON {
/**
* The key path of the variable.
*/
keyPath: string[];
}
/**
* Represents a key path expression, which is used to reference a variable by its key path.
*
* This is the V2 of `KeyPathExpression`, with the following improvements:
* - `returnType` is copied to a new instance to avoid reference issues.
* - Circular reference detection is introduced.
*/
export class KeyPathExpression<
CustomPathJSON extends ASTNodeJSON = KeyPathExpressionJSON
> extends BaseExpression<CustomPathJSON> {
static kind: string = ASTKind.KeyPathExpression;
protected _keyPath: string[] = [];
protected _rawPathJson: CustomPathJSON;
/**
* The key path of the variable.
*/
get keyPath(): string[] {
return this._keyPath;
}
/**
* Get the variable fields referenced by the expression.
* @returns An array of referenced variable fields.
*/
getRefFields(): BaseVariableField[] {
const ref = this.scope.available.getByKeyPath(this._keyPath);
// When refreshing references, check for circular references. If a circular reference exists, do not reference the variable.
if (checkRefCycle(this, [ref])) {
// Prompt that a circular reference exists.
console.warn(
'[CustomKeyPathExpression] checkRefCycle: Reference Cycle Existed',
this.parentFields.map((_field) => _field.key).reverse()
);
return [];
}
return ref ? [ref] : [];
}
/**
* The return type of the expression.
*
* A new `returnType` node is generated directly, instead of reusing the existing one, to ensure that different key paths do not point to the same field.
*/
_returnType: BaseType;
/**
* The return type of the expression.
*/
get returnType() {
return this._returnType;
}
/**
* Parse the business-defined path expression into a key path.
*
* Businesses can quickly customize their own path expressions by modifying this method.
* @param json The path expression defined by the business.
* @returns The key path.
*/
protected parseToKeyPath(json: CustomPathJSON): string[] {
// The default JSON is in KeyPathExpressionJSON format.
return (json as unknown as KeyPathExpressionJSON).keyPath;
}
/**
* Deserializes the `KeyPathExpressionJSON` to the `KeyPathExpression`.
* @param json The `KeyPathExpressionJSON` to deserialize.
*/
fromJSON(json: CustomPathJSON): void {
const keyPath = this.parseToKeyPath(json);
if (!shallowEqual(keyPath, this._keyPath)) {
this._keyPath = keyPath;
this._rawPathJson = json;
// After the keyPath is updated, the referenced variables need to be refreshed.
this.refreshRefs();
}
}
/**
* Get the return type JSON by reference.
* @param _ref The referenced variable field.
* @returns The JSON representation of the return type.
*/
getReturnTypeJSONByRef(_ref: BaseVariableField | undefined): ASTNodeJSON | undefined {
return _ref?.type?.toJSON();
}
constructor(params: CreateASTParams, opts: any) {
super(params, opts);
this.toDispose.pushAll([
// Can be used when the variable list changes (when there are additions or deletions).
this.scope.available.onVariableListChange(() => {
this.refreshRefs();
}),
// When the referable variable pointed to by this._keyPath changes, refresh the reference data.
this.scope.available.onAnyVariableChange((_v) => {
if (_v.key === this._keyPath[0]) {
this.refreshRefs();
}
}),
subsToDisposable(
this.refs$
.pipe(
distinctUntilChanged(
(prev, next) => prev === next,
(_refs) => _refs?.[0]?.type?.hash
)
)
.subscribe((_type) => {
const [ref] = this._refs;
this.updateChildNodeByKey('_returnType', this.getReturnTypeJSONByRef(ref));
})
),
]);
}
/**
* Serialize the `KeyPathExpression` to `KeyPathExpressionJSON`.
* @returns The JSON representation of `KeyPathExpression`.
*/
toJSON() {
return this._rawPathJson;
}
}
@@ -0,0 +1,119 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { shallowEqual } from 'fast-equals';
import { ASTNodeJSON, ASTKind, CreateASTParams } from '../types';
import { BaseType } from '../type';
import { ASTNodeFlags } from '../flags';
import { type BaseVariableField } from '../declaration';
import { BaseExpression } from './base-expression';
/**
* ASTNodeJSON representation of `KeyPathExpression`
*/
export interface KeyPathExpressionJSON {
/**
* The key path of the variable.
*/
keyPath: string[];
}
/**
* @deprecated Use `KeyPathExpression` instead.
* Represents a key path expression, which is used to reference a variable by its key path.
*/
export class LegacyKeyPathExpression<
CustomPathJSON extends ASTNodeJSON = KeyPathExpressionJSON
> extends BaseExpression<CustomPathJSON> {
static kind: string = ASTKind.KeyPathExpression;
protected _keyPath: string[] = [];
protected _rawPathJson: CustomPathJSON;
/**
* The key path of the variable.
*/
get keyPath(): string[] {
return this._keyPath;
}
/**
* Get the variable fields referenced by the expression.
* @returns An array of referenced variable fields.
*/
getRefFields(): BaseVariableField[] {
const ref = this.scope.available.getByKeyPath(this._keyPath);
return ref ? [ref] : [];
}
/**
* The return type of the expression.
*/
get returnType(): BaseType | undefined {
const [refNode] = this._refs || [];
// Get the type of the referenced variable.
if (refNode && refNode.flags & ASTNodeFlags.VariableField) {
return refNode.type;
}
return;
}
/**
* Parse the business-defined path expression into a key path.
*
* Businesses can quickly customize their own path expressions by modifying this method.
* @param json The path expression defined by the business.
* @returns The key path.
*/
protected parseToKeyPath(json: CustomPathJSON): string[] {
// The default JSON is in KeyPathExpressionJSON format.
return (json as unknown as KeyPathExpressionJSON).keyPath;
}
/**
* Deserializes the `KeyPathExpressionJSON` to the `KeyPathExpression`.
* @param json The `KeyPathExpressionJSON` to deserialize.
*/
fromJSON(json: CustomPathJSON): void {
const keyPath = this.parseToKeyPath(json);
if (!shallowEqual(keyPath, this._keyPath)) {
this._keyPath = keyPath;
this._rawPathJson = json;
// After the keyPath is updated, the referenced variables need to be refreshed.
this.refreshRefs();
}
}
constructor(params: CreateASTParams, opts: any) {
super(params, opts);
this.toDispose.pushAll([
// Can be used when the variable list changes (when there are additions or deletions).
this.scope.available.onVariableListChange(() => {
this.refreshRefs();
}),
// When the referable variable pointed to by this._keyPath changes, refresh the reference data.
this.scope.available.onAnyVariableChange((_v) => {
if (_v.key === this._keyPath[0]) {
this.refreshRefs();
}
}),
]);
}
/**
* Serialize the `KeyPathExpression` to `KeyPathExpressionJSON`.
* @returns The JSON representation of `KeyPathExpression`.
*/
toJSON() {
return this._rawPathJson;
}
}
@@ -0,0 +1,96 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { postConstructAST } from '../utils/inversify';
import { ASTKind, ASTNodeJSON } from '../types';
import { BaseType } from '../type';
import { BaseExpression } from './base-expression';
/**
* ASTNodeJSON representation of `WrapArrayExpression`
*/
export interface WrapArrayExpressionJSON {
/**
* The expression to be wrapped.
*/
wrapFor: ASTNodeJSON;
}
/**
* Represents a wrap expression, which wraps an expression with an array.
*/
export class WrapArrayExpression extends BaseExpression<WrapArrayExpressionJSON> {
static kind: string = ASTKind.WrapArrayExpression;
protected _wrapFor: BaseExpression | undefined;
protected _returnType: BaseType | undefined;
/**
* The expression to be wrapped.
*/
get wrapFor() {
return this._wrapFor;
}
/**
* The return type of the expression.
*/
get returnType(): BaseType | undefined {
return this._returnType;
}
/**
* Refresh the return type of the expression.
*/
refreshReturnType() {
// The return value of the wrapped expression.
const childReturnTypeJSON = this.wrapFor?.returnType?.toJSON();
this.updateChildNodeByKey('_returnType', {
kind: ASTKind.Array,
items: childReturnTypeJSON,
});
}
/**
* Get the variable fields referenced by the expression.
* @returns An empty array, as this expression does not reference any variables.
*/
getRefFields(): [] {
return [];
}
/**
* Deserializes the `WrapArrayExpressionJSON` to the `WrapArrayExpression`.
* @param json The `WrapArrayExpressionJSON` to deserialize.
*/
fromJSON({ wrapFor: expression }: WrapArrayExpressionJSON): void {
this.updateChildNodeByKey('_wrapFor', expression);
}
/**
* Serialize the `WrapArrayExpression` to `WrapArrayExpressionJSON`.
* @returns The JSON representation of `WrapArrayExpression`.
*/
toJSON() {
return {
kind: ASTKind.WrapArrayExpression,
wrapFor: this.wrapFor?.toJSON(),
};
}
@postConstructAST()
protected init() {
this.refreshReturnType = this.refreshReturnType.bind(this);
this.toDispose.push(
this.subscribe(this.refreshReturnType, {
selector: (curr) => curr.wrapFor?.returnType,
triggerOnInit: true,
})
);
}
}
@@ -0,0 +1,163 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind, ASTNodeJSON } from './types';
import { StringJSON } from './type/string';
import { MapJSON } from './type/map';
import { ArrayJSON } from './type/array';
import { CustomTypeJSON, ObjectJSON, UnionJSON } from './type';
import {
EnumerateExpressionJSON,
KeyPathExpressionJSON,
WrapArrayExpressionJSON,
} from './expression';
import { PropertyJSON, VariableDeclarationJSON, VariableDeclarationListJSON } from './declaration';
import { ASTNode } from './ast-node';
/**
* Variable-core ASTNode factories.
*/
export namespace ASTFactory {
/**
* Type-related factories.
*/
/**
* Creates a `String` type node.
*/
export const createString = (json?: StringJSON) => ({
kind: ASTKind.String,
...(json || {}),
});
/**
* Creates a `Number` type node.
*/
export const createNumber = () => ({ kind: ASTKind.Number });
/**
* Creates a `Boolean` type node.
*/
export const createBoolean = () => ({ kind: ASTKind.Boolean });
/**
* Creates an `Integer` type node.
*/
export const createInteger = () => ({ kind: ASTKind.Integer });
/**
* Creates an `Object` type node.
*/
export const createObject = (json: ObjectJSON) => ({
kind: ASTKind.Object,
...json,
});
/**
* Creates an `Array` type node.
*/
export const createArray = (json: ArrayJSON) => ({
kind: ASTKind.Array,
...json,
});
/**
* Creates a `Map` type node.
*/
export const createMap = (json: MapJSON) => ({
kind: ASTKind.Map,
...json,
});
/**
* Creates a `Union` type node.
*/
export const createUnion = (json: UnionJSON) => ({
kind: ASTKind.Union,
...json,
});
/**
* Creates a `CustomType` node.
*/
export const createCustomType = (json: CustomTypeJSON) => ({
kind: ASTKind.CustomType,
...json,
});
/**
* Declaration-related factories.
*/
/**
* Creates a `VariableDeclaration` node.
*/
export const createVariableDeclaration = <VariableMeta = any>(
json: VariableDeclarationJSON<VariableMeta>
) => ({
kind: ASTKind.VariableDeclaration,
...json,
});
/**
* Creates a `Property` node.
*/
export const createProperty = <VariableMeta = any>(json: PropertyJSON<VariableMeta>) => ({
kind: ASTKind.Property,
...json,
});
/**
* Creates a `VariableDeclarationList` node.
*/
export const createVariableDeclarationList = (json: VariableDeclarationListJSON) => ({
kind: ASTKind.VariableDeclarationList,
...json,
});
/**
* Expression-related factories.
*/
/**
* Creates an `EnumerateExpression` node.
*/
export const createEnumerateExpression = (json: EnumerateExpressionJSON) => ({
kind: ASTKind.EnumerateExpression,
...json,
});
/**
* Creates a `KeyPathExpression` node.
*/
export const createKeyPathExpression = (json: KeyPathExpressionJSON) => ({
kind: ASTKind.KeyPathExpression,
...json,
});
/**
* Creates a `WrapArrayExpression` node.
*/
export const createWrapArrayExpression = (json: WrapArrayExpressionJSON) => ({
kind: ASTKind.WrapArrayExpression,
...json,
});
/**
* Create by AST Class.
*/
/**
* Creates Type-Safe ASTNodeJSON object based on the provided AST class.
*
* @param targetType Target ASTNode class.
* @param json The JSON data for the node.
* @returns The ASTNode JSON object.
*/
export const create = <JSON extends ASTNodeJSON>(
targetType: { kind: string; new (...args: any[]): ASTNode<JSON> },
json: JSON
) => ({ kind: targetType.kind, ...json });
}
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/**
* ASTNode flags. Stored in the `flags` property of the `ASTNode`.
*/
export enum ASTNodeFlags {
/**
* None.
*/
None = 0,
/**
* Variable Field.
*/
VariableField = 1 << 0,
/**
* Expression.
*/
Expression = 1 << 2,
/**
* # Variable Type Flags
*/
/**
* Basic type.
*/
BasicType = 1 << 3,
/**
* Drillable variable type.
*/
DrilldownType = 1 << 4,
/**
* Enumerable variable type.
*/
EnumerateType = 1 << 5,
/**
* Composite type, currently not in use.
*/
UnionType = 1 << 6,
/**
* Variable type.
*/
VariableType = BasicType | DrilldownType | EnumerateType | UnionType,
}
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export {
type ASTNodeJSON,
ASTKind,
type GetKindJSON,
type GetKindJSONOrKind,
type CreateASTParams,
type GlobalEventActionType,
} from './types';
export { ASTRegisters } from './ast-registers';
export { ASTNode, type ASTNodeRegistry } from './ast-node';
export { ASTNodeFlags } from './flags';
export * from './common';
export * from './declaration';
export * from './type';
export * from './expression';
export { ASTFactory } from './factory';
export { ASTMatch } from './match';
export { injectToAST, postConstructAST } from './utils/inversify';
export { isMatchAST } from './utils/helpers';
@@ -0,0 +1,146 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind } from './types';
import {
type StringType,
type NumberType,
type BooleanType,
type IntegerType,
type ObjectType,
type ArrayType,
type MapType,
type CustomType,
} from './type';
import { ASTNodeFlags } from './flags';
import {
type WrapArrayExpression,
type EnumerateExpression,
type KeyPathExpression,
} from './expression';
import {
type BaseVariableField,
type Property,
type VariableDeclaration,
type VariableDeclarationList,
} from './declaration';
import { type ASTNode } from './ast-node';
/**
* Variable-core ASTNode matchers.
*
* - Typescript code inside if statement will be type guarded.
*/
export namespace ASTMatch {
/**
* # Type-related matchers.
*/
/**
* Check if the node is a `StringType`.
*/
export const isString = (node?: ASTNode): node is StringType => node?.kind === ASTKind.String;
/**
* Check if the node is a `NumberType`.
*/
export const isNumber = (node?: ASTNode): node is NumberType => node?.kind === ASTKind.Number;
/**
* Check if the node is a `BooleanType`.
*/
export const isBoolean = (node?: ASTNode): node is BooleanType => node?.kind === ASTKind.Boolean;
/**
* Check if the node is a `IntegerType`.
*/
export const isInteger = (node?: ASTNode): node is IntegerType => node?.kind === ASTKind.Integer;
/**
* Check if the node is a `ObjectType`.
*/
export const isObject = (node?: ASTNode): node is ObjectType => node?.kind === ASTKind.Object;
/**
* Check if the node is a `ArrayType`.
*/
export const isArray = (node?: ASTNode): node is ArrayType => node?.kind === ASTKind.Array;
/**
* Check if the node is a `MapType`.
*/
export const isMap = (node?: ASTNode): node is MapType => node?.kind === ASTKind.Map;
/**
* Check if the node is a `CustomType`.
*/
export const isCustomType = (node?: ASTNode): node is CustomType =>
node?.kind === ASTKind.CustomType;
/**
* # Declaration-related matchers.
*/
/**
* Check if the node is a `VariableDeclaration`.
*/
export const isVariableDeclaration = <VariableMeta = any>(
node?: ASTNode
): node is VariableDeclaration<VariableMeta> => node?.kind === ASTKind.VariableDeclaration;
/**
* Check if the node is a `Property`.
*/
export const isProperty = <VariableMeta = any>(node?: ASTNode): node is Property<VariableMeta> =>
node?.kind === ASTKind.Property;
/**
* Check if the node is a `BaseVariableField`.
*/
export const isBaseVariableField = (node?: ASTNode): node is BaseVariableField =>
!!(node?.flags || 0 & ASTNodeFlags.VariableField);
/**
* Check if the node is a `VariableDeclarationList`.
*/
export const isVariableDeclarationList = (node?: ASTNode): node is VariableDeclarationList =>
node?.kind === ASTKind.VariableDeclarationList;
/**
* # Expression-related matchers.
*/
/**
* Check if the node is a `EnumerateExpression`.
*/
export const isEnumerateExpression = (node?: ASTNode): node is EnumerateExpression =>
node?.kind === ASTKind.EnumerateExpression;
/**
* Check if the node is a `WrapArrayExpression`.
*/
export const isWrapArrayExpression = (node?: ASTNode): node is WrapArrayExpression =>
node?.kind === ASTKind.WrapArrayExpression;
/**
* Check if the node is a `KeyPathExpression`.
*/
export const isKeyPathExpression = (node?: ASTNode): node is KeyPathExpression =>
node?.kind === ASTKind.KeyPathExpression;
/**
* Check ASTNode Match by ASTClass
*
* @param node ASTNode to be checked.
* @param targetType Target ASTNode class.
* @returns Whether the node is of the target type.
*/
export function is<TargetASTNode extends ASTNode>(
node?: ASTNode,
targetType?: { kind: string; new (...args: any[]): TargetASTNode }
): node is TargetASTNode {
return node?.kind === targetType?.kind;
}
}
@@ -0,0 +1,109 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { parseTypeJsonOrKind } from '../utils/helpers';
import { ASTKind, ASTNodeJSON, ASTNodeJSONOrKind } from '../types';
import { ASTNodeFlags } from '../flags';
import { type BaseVariableField } from '../declaration';
import { BaseType } from './base-type';
/**
* ASTNodeJSON representation of `ArrayType`
*/
export interface ArrayJSON {
/**
* The type of the items in the array.
*/
items?: ASTNodeJSONOrKind;
}
/**
* Represents an array type.
*/
export class ArrayType extends BaseType<ArrayJSON> {
public flags: ASTNodeFlags = ASTNodeFlags.DrilldownType | ASTNodeFlags.EnumerateType;
static kind: string = ASTKind.Array;
/**
* The type of the items in the array.
*/
items: BaseType;
/**
* Deserializes the `ArrayJSON` to the `ArrayType`.
* @param json The `ArrayJSON` to deserialize.
*/
fromJSON({ items }: ArrayJSON): void {
this.updateChildNodeByKey('items', parseTypeJsonOrKind(items));
}
/**
* Whether the items type can be drilled down.
*/
get canDrilldownItems(): boolean {
return !!(this.items?.flags & ASTNodeFlags.DrilldownType);
}
/**
* Get a variable field by key path.
* @param keyPath The key path to search for.
* @returns The variable field if found, otherwise `undefined`.
*/
getByKeyPath(keyPath: string[]): BaseVariableField | undefined {
const [curr, ...rest] = keyPath || [];
if (curr === '0' && this.canDrilldownItems) {
// The first item of the array.
return this.items.getByKeyPath(rest);
}
return undefined;
}
/**
* Check if the current type is equal to the target type.
* @param targetTypeJSONOrKind The type to compare with.
* @returns `true` if the types are equal, `false` otherwise.
*/
public isTypeEqual(targetTypeJSONOrKind?: ASTNodeJSONOrKind): boolean {
const targetTypeJSON = parseTypeJsonOrKind(targetTypeJSONOrKind);
const isSuperEqual = super.isTypeEqual(targetTypeJSONOrKind);
if (targetTypeJSON?.weak || targetTypeJSON?.kind === ASTKind.Union) {
return isSuperEqual;
}
return (
targetTypeJSON &&
isSuperEqual &&
// Weak comparison, only need to compare the Kind.
(targetTypeJSON?.weak || this.customStrongEqual(targetTypeJSON))
);
}
/**
* Array strong comparison.
* @param targetTypeJSON The type to compare with.
* @returns `true` if the types are equal, `false` otherwise.
*/
protected customStrongEqual(targetTypeJSON: ASTNodeJSON): boolean {
if (!this.items) {
return !(targetTypeJSON as ArrayJSON)?.items;
}
return this.items?.isTypeEqual((targetTypeJSON as ArrayJSON).items);
}
/**
* Serialize the `ArrayType` to `ArrayJSON`
* @returns The JSON representation of `ArrayType`.
*/
toJSON() {
return {
kind: ASTKind.Array,
items: this.items?.toJSON(),
};
}
}
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { parseTypeJsonOrKind } from '../utils/helpers';
import { ASTKind, ASTNodeJSON, ASTNodeJSONOrKind } from '../types';
import { ASTNodeFlags } from '../flags';
import { BaseVariableField } from '../declaration';
import { ASTNode } from '../ast-node';
import { UnionJSON } from './union';
/**
* Base class for all types.
*
* All other types should extend this class.
*/
export abstract class BaseType<JSON extends ASTNodeJSON = any> extends ASTNode<JSON> {
public flags: number = ASTNodeFlags.BasicType;
/**
* Check if the current type is equal to the target type.
* @param targetTypeJSONOrKind The type to compare with.
* @returns `true` if the types are equal, `false` otherwise.
*/
public isTypeEqual(targetTypeJSONOrKind?: ASTNodeJSONOrKind): boolean {
const targetTypeJSON = parseTypeJsonOrKind(targetTypeJSONOrKind);
// If it is a Union type, it is sufficient for one of the subtypes to be equal.
if (targetTypeJSON?.kind === ASTKind.Union) {
return ((targetTypeJSON as UnionJSON)?.types || [])?.some((_subType) =>
this.isTypeEqual(_subType)
);
}
return this.kind === targetTypeJSON?.kind;
}
/**
* Get a variable field by key path.
*
* This method should be implemented by drillable types.
* @param keyPath The key path to search for.
* @returns The variable field if found, otherwise `undefined`.
*/
getByKeyPath(keyPath: string[] = []): BaseVariableField | undefined {
throw new Error(`Get By Key Path is not implemented for Type: ${this.kind}`);
}
}
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind } from '../types';
import { BaseType } from './base-type';
/**
* Represents a boolean type.
*/
export class BooleanType extends BaseType {
static kind: string = ASTKind.Boolean;
/**
* Deserializes the `BooleanJSON` to the `BooleanType`.
* @param json The `BooleanJSON` to deserialize.
*/
fromJSON(): void {
// noop
}
toJSON() {
return {};
}
}
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { parseTypeJsonOrKind } from '../utils/helpers';
import { ASTKind, ASTNodeJSONOrKind } from '../types';
import { type UnionJSON } from './union';
import { BaseType } from './base-type';
/**
* ASTNodeJSON representation of `CustomType`
*/
export interface CustomTypeJSON {
/**
* The name of the custom type.
*/
typeName: string;
}
/**
* Represents a custom type.
*/
export class CustomType extends BaseType<CustomTypeJSON> {
static kind: string = ASTKind.CustomType;
protected _typeName: string;
/**
* The name of the custom type.
*/
get typeName(): string {
return this._typeName;
}
/**
* Deserializes the `CustomTypeJSON` to the `CustomType`.
* @param json The `CustomTypeJSON` to deserialize.
*/
fromJSON(json: CustomTypeJSON): void {
if (this._typeName !== json.typeName) {
this._typeName = json.typeName;
this.fireChange();
}
}
/**
* Check if the current type is equal to the target type.
* @param targetTypeJSONOrKind The type to compare with.
* @returns `true` if the types are equal, `false` otherwise.
*/
public isTypeEqual(targetTypeJSONOrKind?: ASTNodeJSONOrKind): boolean {
const targetTypeJSON = parseTypeJsonOrKind(targetTypeJSONOrKind);
// If it is a Union type, it is sufficient for one of the subtypes to be equal.
if (targetTypeJSON?.kind === ASTKind.Union) {
return ((targetTypeJSON as UnionJSON)?.types || [])?.some((_subType) =>
this.isTypeEqual(_subType)
);
}
return targetTypeJSON?.kind === this.kind && targetTypeJSON?.typeName === this.typeName;
}
toJSON() {
return {
typeName: this.typeName,
};
}
}
@@ -0,0 +1,19 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { StringType } from './string';
export { IntegerType } from './integer';
export { BooleanType } from './boolean';
export { NumberType } from './number';
export { ArrayType } from './array';
export { MapType } from './map';
export {
type ObjectJSON as ObjectJSON,
ObjectType,
type ObjectPropertiesChangeAction,
} from './object';
export { BaseType } from './base-type';
export { type UnionJSON } from './union';
export { CustomType, type CustomTypeJSON } from './custom-type';
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind } from '../types';
import { ASTNodeFlags } from '../flags';
import { BaseType } from './base-type';
/**
* Represents an integer type.
*/
export class IntegerType extends BaseType {
public flags: ASTNodeFlags = ASTNodeFlags.BasicType;
static kind: string = ASTKind.Integer;
/**
* Deserializes the `IntegerJSON` to the `IntegerType`.
* @param json The `IntegerJSON` to deserialize.
*/
fromJSON(): void {
// noop
}
toJSON() {
return {};
}
}
@@ -0,0 +1,96 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { parseTypeJsonOrKind } from '../utils/helpers';
import { ASTKind, ASTNodeJSON, ASTNodeJSONOrKind } from '../types';
import { BaseType } from './base-type';
/**
* ASTNodeJSON representation of `MapType`
*/
export interface MapJSON {
/**
* The type of the keys in the map.
*/
keyType?: ASTNodeJSONOrKind;
/**
* The type of the values in the map.
*/
valueType?: ASTNodeJSONOrKind;
}
/**
* Represents a map type.
*/
export class MapType extends BaseType<MapJSON> {
static kind: string = ASTKind.Map;
/**
* The type of the keys in the map.
*/
keyType: BaseType;
/**
* The type of the values in the map.
*/
valueType: BaseType;
/**
* Deserializes the `MapJSON` to the `MapType`.
* @param json The `MapJSON` to deserialize.
*/
fromJSON({ keyType = ASTKind.String, valueType }: MapJSON): void {
// Key defaults to String.
this.updateChildNodeByKey('keyType', parseTypeJsonOrKind(keyType));
this.updateChildNodeByKey('valueType', parseTypeJsonOrKind(valueType));
}
/**
* Check if the current type is equal to the target type.
* @param targetTypeJSONOrKind The type to compare with.
* @returns `true` if the types are equal, `false` otherwise.
*/
public isTypeEqual(targetTypeJSONOrKind?: ASTNodeJSONOrKind): boolean {
const targetTypeJSON = parseTypeJsonOrKind(targetTypeJSONOrKind);
const isSuperEqual = super.isTypeEqual(targetTypeJSONOrKind);
if (targetTypeJSON?.weak || targetTypeJSON?.kind === ASTKind.Union) {
return isSuperEqual;
}
return (
targetTypeJSON &&
isSuperEqual &&
// Weak comparison, only need to compare the Kind.
(targetTypeJSON?.weak || this.customStrongEqual(targetTypeJSON))
);
}
/**
* Map strong comparison.
* @param targetTypeJSON The type to compare with.
* @returns `true` if the types are equal, `false` otherwise.
*/
protected customStrongEqual(targetTypeJSON: ASTNodeJSON): boolean {
const { keyType = ASTKind.String, valueType } = targetTypeJSON as MapJSON;
const isValueTypeEqual =
(!valueType && !this.valueType) || this.valueType?.isTypeEqual(valueType);
return isValueTypeEqual && this.keyType?.isTypeEqual(keyType);
}
/**
* Serialize the node to a JSON object.
* @returns The JSON representation of the node.
*/
toJSON() {
return {
kind: ASTKind.Map,
keyType: this.keyType?.toJSON(),
valueType: this.valueType?.toJSON(),
};
}
}
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind } from '../types';
import { BaseType } from './base-type';
/**
* Represents a number type.
*/
export class NumberType extends BaseType {
static kind: string = ASTKind.Number;
/**
* Deserializes the `NumberJSON` to the `NumberType`.
* @param json The `NumberJSON` to deserialize.
*/
fromJSON(): void {
// noop
}
toJSON() {
return {};
}
}
@@ -0,0 +1,185 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { xor } from 'lodash-es';
import { parseTypeJsonOrKind } from '../utils/helpers';
import { ASTNodeJSON, ASTKind, ASTNodeJSONOrKind, type GlobalEventActionType } from '../types';
import { ASTNodeFlags } from '../flags';
import { Property, type PropertyJSON } from '../declaration/property';
import { BaseType } from './base-type';
/**
* ASTNodeJSON representation of `ObjectType`
*/
export interface ObjectJSON<VariableMeta = any> {
/**
* The properties of the object.
*
* The `properties` of an Object must be of type `Property`, so the business can omit the `kind` field.
*/
properties?: PropertyJSON<VariableMeta>[];
}
/**
* Action type for object properties change.
*/
export type ObjectPropertiesChangeAction = GlobalEventActionType<
'ObjectPropertiesChange',
{
prev: Property[];
next: Property[];
},
ObjectType
>;
/**
* Represents an object type.
*/
export class ObjectType extends BaseType<ObjectJSON> {
public flags: ASTNodeFlags = ASTNodeFlags.DrilldownType;
static kind: string = ASTKind.Object;
/**
* A map of property keys to `Property` instances.
*/
propertyTable: Map<string, Property> = new Map();
/**
* An array of `Property` instances.
*/
properties: Property[];
/**
* Deserializes the `ObjectJSON` to the `ObjectType`.
* @param json The `ObjectJSON` to deserialize.
*/
fromJSON({ properties }: ObjectJSON): void {
const removedKeys = new Set(this.propertyTable.keys());
const prev = [...(this.properties || [])];
// Iterate over the new properties.
this.properties = (properties || []).map((property: PropertyJSON) => {
const existProperty = this.propertyTable.get(property.key);
removedKeys.delete(property.key);
if (existProperty) {
existProperty.fromJSON(property as PropertyJSON);
return existProperty;
} else {
const newProperty = this.createChildNode({
...property,
kind: ASTKind.Property,
}) as Property;
this.fireChange();
this.propertyTable.set(property.key, newProperty);
// TODO: When a child node is actively destroyed, delete the information in the table.
return newProperty;
}
});
// Delete properties that no longer exist.
removedKeys.forEach((key) => {
const property = this.propertyTable.get(key);
property?.dispose();
this.propertyTable.delete(key);
this.fireChange();
});
this.dispatchGlobalEvent<ObjectPropertiesChangeAction>({
type: 'ObjectPropertiesChange',
payload: {
prev,
next: [...this.properties],
},
});
}
/**
* Serialize the `ObjectType` to `ObjectJSON`.
* @returns The JSON representation of `ObjectType`.
*/
toJSON() {
return {
properties: this.properties.map((_property) => _property.toJSON()),
};
}
/**
* Get a variable field by key path.
* @param keyPath The key path to search for.
* @returns The variable field if found, otherwise `undefined`.
*/
getByKeyPath(keyPath: string[]): Property | undefined {
const [curr, ...restKeyPath] = keyPath;
const property = this.propertyTable.get(curr);
// Found the end of the path.
if (!restKeyPath.length) {
return property;
}
// Otherwise, continue searching downwards.
if (property?.type && property?.type?.flags & ASTNodeFlags.DrilldownType) {
return property.type.getByKeyPath(restKeyPath) as Property | undefined;
}
return undefined;
}
/**
* Check if the current type is equal to the target type.
* @param targetTypeJSONOrKind The type to compare with.
* @returns `true` if the types are equal, `false` otherwise.
*/
public isTypeEqual(targetTypeJSONOrKind?: ASTNodeJSONOrKind): boolean {
const targetTypeJSON = parseTypeJsonOrKind(targetTypeJSONOrKind);
const isSuperEqual = super.isTypeEqual(targetTypeJSONOrKind);
if (targetTypeJSON?.weak || targetTypeJSON?.kind === ASTKind.Union) {
return isSuperEqual;
}
return (
targetTypeJSON &&
isSuperEqual &&
// Weak comparison, only need to compare the Kind.
(targetTypeJSON?.weak || this.customStrongEqual(targetTypeJSON))
);
}
/**
* Object type strong comparison.
* @param targetTypeJSON The type to compare with.
* @returns `true` if the types are equal, `false` otherwise.
*/
protected customStrongEqual(targetTypeJSON: ASTNodeJSON): boolean {
const targetProperties = (targetTypeJSON as ObjectJSON).properties || [];
const sourcePropertyKeys = Array.from(this.propertyTable.keys());
const targetPropertyKeys = targetProperties.map((_target) => _target.key);
const isKeyStrongEqual = !xor(sourcePropertyKeys, targetPropertyKeys).length;
return (
isKeyStrongEqual &&
targetProperties.every((targetProperty) => {
const sourceProperty = this.propertyTable.get(targetProperty.key);
return (
sourceProperty &&
sourceProperty.key === targetProperty.key &&
sourceProperty.type?.isTypeEqual(targetProperty?.type)
);
})
);
}
}
@@ -0,0 +1,55 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTKind } from '../types';
import { ASTNodeFlags } from '../flags';
import { BaseType } from './base-type';
/**
* ASTNodeJSON representation of the `StringType`.
*/
export interface StringJSON {
/**
* see https://json-schema.org/understanding-json-schema/reference/type#format
*/
format?: string;
}
export class StringType extends BaseType<StringJSON> {
public flags: ASTNodeFlags = ASTNodeFlags.BasicType;
static kind: string = ASTKind.String;
protected _format?: string;
/**
* see https://json-schema.org/understanding-json-schema/reference/string#format
*/
get format() {
return this._format;
}
/**
* Deserialize the `StringJSON` to the `StringType`.
*
* @param json StringJSON representation of the `StringType`.
*/
fromJSON(json?: StringJSON): void {
if (json?.format !== this._format) {
this._format = json?.format;
this.fireChange();
}
}
/**
* Serialize the `StringType` to `StringJSON`.
* @returns The JSON representation of `StringType`.
*/
toJSON() {
return {
format: this._format,
};
}
}
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTNodeJSONOrKind } from '../types';
/**
* ASTNodeJSON representation of `UnionType`, which union multiple `BaseType`.
*/
export interface UnionJSON {
types?: ASTNodeJSONOrKind[];
}
@@ -0,0 +1,188 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type Observer } from 'rxjs';
import { type Scope } from '../scope';
import { type ASTNode } from './ast-node';
export type ASTKindType = string;
export type Identifier = string;
/**
* ASTNodeJSON is the JSON representation of an ASTNode.
*/
export interface ASTNodeJSON {
/**
* Kind is the type of the AST node.
*/
kind?: ASTKindType;
/**
* Key is the unique identifier of the node.
* If not provided, the node will generate a default key value.
*/
key?: Identifier;
[key: string]: any;
}
/**
* Core AST node types.
*/
export enum ASTKind {
/**
* # Type-related.
* - A set of type AST nodes based on JSON types is implemented internally by default.
*/
/**
* String type.
*/
String = 'String',
/**
* Number type.
*/
Number = 'Number',
/**
* Integer type.
*/
Integer = 'Integer',
/**
* Boolean type.
*/
Boolean = 'Boolean',
/**
* Object type.
*/
Object = 'Object',
/**
* Array type.
*/
Array = 'Array',
/**
* Map type.
*/
Map = 'Map',
/**
* Union type.
* Commonly used for type checking, generally not exposed to the business.
*/
Union = 'Union',
/**
* Any type.
* Commonly used for business logic.
*/
Any = 'Any',
/**
* Custom type.
* For business-defined types.
*/
CustomType = 'CustomType',
/**
* # Declaration-related.
*/
/**
* Field definition for Object drill-down.
*/
Property = 'Property',
/**
* Variable declaration.
*/
VariableDeclaration = 'VariableDeclaration',
/**
* Variable declaration list.
*/
VariableDeclarationList = 'VariableDeclarationList',
/**
* # Expression-related.
*/
/**
* Access fields on variables through the path system.
*/
KeyPathExpression = 'KeyPathExpression',
/**
* Iterate over specified data.
*/
EnumerateExpression = 'EnumerateExpression',
/**
* Wrap with Array Type.
*/
WrapArrayExpression = 'WrapArrayExpression',
/**
* # General-purpose AST nodes.
*/
/**
* General-purpose List<ASTNode> storage node.
*/
ListNode = 'ListNode',
/**
* General-purpose data storage node.
*/
DataNode = 'DataNode',
/**
* General-purpose Map<string, ASTNode> storage node.
*/
MapNode = 'MapNode',
}
export interface CreateASTParams {
scope: Scope;
key?: Identifier;
parent?: ASTNode;
}
export type ASTNodeJSONOrKind = string | ASTNodeJSON;
export type ObserverOrNext<T> = Partial<Observer<T>> | ((value: T) => void);
export interface SubscribeConfig<This, Data> {
// Merge all changes within one animationFrame into a single one.
debounceAnimation?: boolean;
// Respond with a value by default upon subscription.
triggerOnInit?: boolean;
selector?: (curr: This) => Data;
}
/**
* TypeUtils to get the JSON representation of an AST node with a specific kind.
*/
export type GetKindJSON<KindType extends string, JSON extends ASTNodeJSON> = {
kind: KindType;
key?: Identifier;
} & JSON;
/**
* TypeUtils to get the JSON representation of an AST node with a specific kind or just the kind string.
*/
export type GetKindJSONOrKind<KindType extends string, JSON extends ASTNodeJSON> =
| ({
kind: KindType;
key?: Identifier;
} & JSON)
| KindType;
/**
* Global event action type.
* - Global event might be dispatched from `ASTNode` or `Scope`.
*/
export interface GlobalEventActionType<
Type = string,
Payload = any,
AST extends ASTNode = ASTNode
> {
type: Type;
payload?: Payload;
ast?: AST;
}
export type NewASTAction = GlobalEventActionType<'NewAST'>;
export type UpdateASTAction = GlobalEventActionType<'UpdateAST'>;
export type DisposeASTAction = GlobalEventActionType<'DisposeAST'>;
@@ -0,0 +1,61 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { intersection } from 'lodash-es';
import { ASTNodeFlags } from '../flags';
import { type BaseExpression } from '../expression';
import { type BaseVariableField } from '../declaration';
import { type ASTNode } from '../ast-node';
import { getParentFields } from './variable-field';
import { getAllChildren } from './helpers';
/**
* Get all variables referenced by child ASTs.
* @param ast The ASTNode to traverse.
* @returns All variables referenced by child ASTs.
*/
export function getAllRefs(ast: ASTNode): BaseVariableField[] {
return getAllChildren(ast)
.filter((_child) => _child.flags & ASTNodeFlags.Expression)
.map((_child) => (_child as BaseExpression).refs)
.flat()
.filter(Boolean) as BaseVariableField[];
}
/**
* Checks for circular references.
* @param curr The current expression.
* @param refNode The referenced variable node.
* @returns Whether a circular reference exists.
*/
export function checkRefCycle(
curr: BaseExpression,
refNodes: (BaseVariableField | undefined)[]
): boolean {
// If there are no circular references in the scope, then it is impossible to have a circular reference.
if (
intersection(curr.scope.coverScopes, refNodes.map((_ref) => _ref?.scope).filter(Boolean))
.length === 0
) {
return false;
}
// BFS traversal.
const visited = new Set<BaseVariableField>();
const queue = [...refNodes];
while (queue.length) {
const currNode = queue.shift()!;
visited.add(currNode);
for (const ref of getAllRefs(currNode).filter((_ref) => !visited.has(_ref))) {
queue.push(ref);
}
}
// If the referenced variables include the parent variable of the expression, then there is a circular reference.
return intersection(Array.from(visited), getParentFields(curr)).length > 0;
}
@@ -0,0 +1,73 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTNodeJSON, ASTNodeJSONOrKind } from '../types';
import { ASTMatch } from '../match';
import { ASTNode } from '../ast-node';
export function updateChildNodeHelper(
this: ASTNode,
{
getChildNode,
updateChildNode,
removeChildNode,
nextJSON,
}: {
getChildNode: () => ASTNode | undefined;
updateChildNode: (nextNode: ASTNode) => void;
removeChildNode: () => void;
nextJSON?: ASTNodeJSON;
}
): ASTNode | undefined {
const currNode: ASTNode | undefined = getChildNode();
const isNewKind = currNode?.kind !== nextJSON?.kind;
// If `nextJSON` does not pass a key value, the key value remains unchanged by default.
const isNewKey = nextJSON?.key && nextJSON?.key !== currNode?.key;
if (isNewKind || isNewKey) {
// The previous node needs to be destroyed.
if (currNode) {
currNode.dispose();
removeChildNode();
}
if (nextJSON) {
const newNode = this.createChildNode(nextJSON);
updateChildNode(newNode);
this.fireChange();
return newNode;
} else {
// Also trigger an update when deleting a child node directly.
this.fireChange();
}
} else if (nextJSON) {
currNode?.fromJSON(nextJSON);
}
return currNode;
}
export function parseTypeJsonOrKind(typeJSONOrKind?: ASTNodeJSONOrKind): ASTNodeJSON | undefined {
return typeof typeJSONOrKind === 'string' ? { kind: typeJSONOrKind } : typeJSONOrKind;
}
// Get all children.
export function getAllChildren(ast: ASTNode): ASTNode[] {
return [...ast.children, ...ast.children.map((_child) => getAllChildren(_child)).flat()];
}
/**
* isMatchAST is same as ASTMatch.is
* @param node
* @param targetType
* @returns
*/
export function isMatchAST<TargetASTNode extends ASTNode>(
node?: ASTNode,
targetType?: { kind: string; new (...args: any[]): TargetASTNode }
): node is TargetASTNode {
return ASTMatch.is(node, targetType);
}
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { interfaces } from 'inversify';
import { type ASTNode } from '../ast-node';
export const injectToAST = (serviceIdentifier: interfaces.ServiceIdentifier) =>
function (target: any, propertyKey: string) {
if (!serviceIdentifier) {
throw new Error(
`ServiceIdentifier ${serviceIdentifier} in @lazyInject is Empty, it might be caused by file circular dependency, please check it.`
);
}
const descriptor = {
get() {
const container = (this as ASTNode).scope.variableEngine.container;
return container.get(serviceIdentifier);
},
set() {},
configurable: true,
enumerable: true,
} as any;
// Object.defineProperty(target, propertyKey, descriptor);
return descriptor;
};
export const POST_CONSTRUCT_AST_SYMBOL = Symbol('post_construct_ast');
export const postConstructAST = () => (target: any, propertyKey: string) => {
// Only run once.
if (!Reflect.hasMetadata(POST_CONSTRUCT_AST_SYMBOL, target)) {
Reflect.defineMetadata(POST_CONSTRUCT_AST_SYMBOL, propertyKey, target);
} else {
throw Error('Duplication Post Construct AST');
}
};
@@ -0,0 +1,5 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ASTNodeFlags } from '../flags';
import { BaseVariableField } from '../declaration';
import { ASTNode } from '../ast-node';
/**
* Parent variable fields, sorted from nearest to farthest.
*/
export function getParentFields(ast: ASTNode): BaseVariableField[] {
let curr = ast.parent;
const res: BaseVariableField[] = [];
while (curr) {
if (curr.flags & ASTNodeFlags.VariableField) {
res.push(curr as BaseVariableField);
}
curr = curr.parent;
}
return res;
}
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { VariableContainerModule } from './variable-container-module';
export { VariableEngine } from './variable-engine';
export { VariableEngineProvider } from './providers';
export * from './react';
export * from './scope';
export * from './ast';
export * from './services';
export { type Observer } from 'rxjs';
@@ -0,0 +1,22 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { interfaces } from 'inversify';
import { type VariableEngine } from './variable-engine';
/**
* A provider for dynamically obtaining the `VariableEngine` instance.
* This is used to prevent circular dependencies when injecting `VariableEngine`.
*/
export const VariableEngineProvider = Symbol('DynamicVariableEngine');
export type VariableEngineProvider = () => VariableEngine;
/**
* A provider for obtaining the Inversify container instance.
* This allows other parts of the application, like AST nodes, to access the container for dependency injection.
*/
export const ContainerProvider = Symbol('ContainerProvider');
export type ContainerProvider = () => interfaces.Container;
@@ -0,0 +1,66 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/* eslint-disable react/prop-types */
import React, { createContext, useContext } from 'react';
import { Scope } from '../scope';
interface ScopeContextProps {
scope: Scope;
}
const ScopeContext = createContext<ScopeContextProps>(null!);
/**
* ScopeProvider provides the scope to its children via context.
*/
export const ScopeProvider = (
props: React.PropsWithChildren<{
/**
* scope used in the context
*/
scope?: Scope;
/**
* @deprecated use scope prop instead, this is kept for backward compatibility
*/
value?: ScopeContextProps;
}>
) => {
const { scope, value, children } = props;
const scopeToUse = scope || value?.scope;
if (!scopeToUse) {
throw new Error('[ScopeProvider] scope is required');
}
return <ScopeContext.Provider value={{ scope: scopeToUse }}>{children}</ScopeContext.Provider>;
};
/**
* useCurrentScope returns the scope provided by ScopeProvider.
* @returns
*/
export const useCurrentScope = <Strict extends boolean = false>(params?: {
/**
* whether to throw error when no scope in ScopeProvider is found
*/
strict: Strict;
}): Strict extends true ? Scope : Scope | undefined => {
const { strict = false } = params || {};
const context = useContext(ScopeContext);
if (!context) {
if (strict) {
throw new Error('useCurrentScope must be used within a <ScopeProvider scope={scope}>');
}
console.warn('useCurrentScope should be used within a <ScopeProvider scope={scope}>');
}
return context?.scope;
};
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect } from 'react';
import { useRefresh, useService } from '@flowgram.ai/core';
import { useCurrentScope } from '../context';
import { VariableEngine } from '../../variable-engine';
import { VariableDeclaration } from '../../ast';
/**
* Get available variable list in the current scope.
*
* - If no scope, return global variable list.
* - The hook is reactive to variable list or any variables change.
*/
export function useAvailableVariables(): VariableDeclaration[] {
const scope = useCurrentScope();
const variableEngine: VariableEngine = useService(VariableEngine);
const refresh = useRefresh();
useEffect(() => {
// 没有作用域时,监听全局变量表
if (!scope) {
const disposable = variableEngine.globalVariableTable.onListOrAnyVarChange(() => {
refresh();
});
return () => disposable.dispose();
}
const disposable = scope.available.onDataChange(() => {
refresh();
});
return () => disposable.dispose();
}, []);
// 没有作用域时,使用全局变量表
return scope ? scope.available.variables : variableEngine.globalVariableTable.variables;
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect } from 'react';
import { useRefresh } from '@flowgram.ai/core';
import { useCurrentScope } from '../context';
import { VariableDeclaration } from '../../ast';
/**
* Get output variable list in the current scope.
*
* - The hook is reactive to variable list or any variables change.
*/
export function useOutputVariables(): VariableDeclaration[] {
const scope = useCurrentScope();
const refresh = useRefresh();
useEffect(() => {
if (!scope) {
throw new Error(
'[useOutputVariables]: No scope found, useOutputVariables must be used in <ScopeProvider>'
);
}
const disposable = scope.output.onListOrAnyVarChange(() => {
refresh();
});
return () => disposable.dispose();
}, []);
return scope?.output.variables || [];
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect } from 'react';
import { useRefresh } from '@flowgram.ai/core';
import { useCurrentScope } from '../context';
import { ScopeAvailableData } from '../../scope/datas';
/**
* Get the available variables in the current scope.
* 获取作用域的可访问变量
*
* @returns the available variables in the current scope
*/
export function useScopeAvailable(params?: { autoRefresh?: boolean }): ScopeAvailableData {
const { autoRefresh = true } = params || {};
const scope = useCurrentScope({ strict: true });
const refresh = useRefresh();
useEffect(() => {
if (!autoRefresh) {
return () => null;
}
const disposable = scope.available.onListOrAnyVarChange(() => {
refresh();
});
return () => disposable.dispose();
}, [autoRefresh]);
return scope.available;
}
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { ScopeProvider, useCurrentScope } from './context';
export { useScopeAvailable } from './hooks/use-scope-available';
export { useAvailableVariables } from './hooks/use-available-variables';
export { useOutputVariables } from './hooks/use-output-variables';
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { ScopeOutputData } from './scope-output-data';
export { ScopeAvailableData } from './scope-available-data';
export { ScopeEventData } from './scope-event-data';
@@ -0,0 +1,234 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
Observable,
Subject,
animationFrameScheduler,
debounceTime,
distinctUntilChanged,
map,
merge,
share,
skip,
startWith,
switchMap,
tap,
} from 'rxjs';
import { flatten } from 'lodash-es';
import { shallowEqual } from 'fast-equals';
import { Disposable } from '@flowgram.ai/utils';
import { Emitter } from '@flowgram.ai/utils';
import { IVariableTable } from '../types';
import { type Scope } from '../scope';
import { subsToDisposable } from '../../utils/toDisposable';
import { createMemo } from '../../utils/memo';
import { SubscribeConfig } from '../../ast/types';
import { ASTNode, BaseVariableField, VariableDeclaration } from '../../ast';
/**
* Manages the available variables within a scope.
*/
export class ScopeAvailableData {
protected memo = createMemo();
/**
* The global variable table from the variable engine.
*/
get globalVariableTable(): IVariableTable {
return this.scope.variableEngine.globalVariableTable;
}
protected _version: number = 0;
protected refresh$: Subject<void> = new Subject();
protected _variables: VariableDeclaration[] = [];
/**
* The current version of the available data, which increments on each change.
*/
get version() {
return this._version;
}
protected bumpVersion() {
this._version = this._version + 1;
if (this._version === Number.MAX_SAFE_INTEGER) {
this._version = 0;
}
}
/**
* Refreshes the list of available variables.
* This should be called when the dependencies of the scope change.
*/
refresh(): void {
// Do not trigger refresh for a disposed scope.
if (this.scope.disposed) {
return;
}
this.refresh$.next();
}
/**
* An observable that emits when the list of available variables changes.
*/
protected variables$: Observable<VariableDeclaration[]> = this.refresh$.pipe(
// Map to the flattened list of variables from all dependency scopes.
map(() => flatten(this.depScopes.map((scope) => scope.output.variables || []))),
// Use shallow equality to check if the variable list has changed.
distinctUntilChanged<VariableDeclaration[]>(shallowEqual),
share()
);
/**
* An observable that emits when any variable in the available list changes its value.
*/
protected anyVariableChange$: Observable<VariableDeclaration> = this.variables$.pipe(
switchMap((_variables) =>
merge(
..._variables.map((_v) =>
_v.value$.pipe<any>(
// Skip the initial value of the BehaviorSubject.
skip(1)
)
)
)
),
share()
);
/**
* Subscribes to changes in any variable's value in the available list.
* @param observer A function to be called with the changed variable.
* @returns A disposable to unsubscribe from the changes.
*/
onAnyVariableChange(observer: (changedVariable: VariableDeclaration) => void) {
return subsToDisposable(this.anyVariableChange$.subscribe(observer));
}
/**
* Subscribes to changes in the list of available variables.
* @param observer A function to be called with the new list of variables.
* @returns A disposable to unsubscribe from the changes.
*/
onVariableListChange(observer: (variables: VariableDeclaration[]) => void) {
return subsToDisposable(this.variables$.subscribe(observer));
}
/**
* @deprecated
*/
protected onDataChangeEmitter = new Emitter<VariableDeclaration[]>();
protected onListOrAnyVarChangeEmitter = new Emitter<VariableDeclaration[]>();
/**
* @deprecated use available.onListOrAnyVarChange instead
*/
public onDataChange = this.onDataChangeEmitter.event;
/**
* An event that fires when the variable list changes or any variable's value is updated.
*/
public onListOrAnyVarChange = this.onListOrAnyVarChangeEmitter.event;
constructor(public readonly scope: Scope) {
this.scope.toDispose.pushAll([
this.onVariableListChange((_variables) => {
this._variables = _variables;
this.memo.clear();
this.onDataChangeEmitter.fire(this._variables);
this.bumpVersion();
this.onListOrAnyVarChangeEmitter.fire(this._variables);
}),
this.onAnyVariableChange(() => {
this.onDataChangeEmitter.fire(this._variables);
this.bumpVersion();
this.onListOrAnyVarChangeEmitter.fire(this._variables);
}),
Disposable.create(() => {
this.refresh$.complete();
this.refresh$.unsubscribe();
}),
]);
}
/**
* Gets the list of available variables.
*/
get variables(): VariableDeclaration[] {
return this._variables;
}
/**
* Gets the keys of the available variables.
*/
get variableKeys(): string[] {
return this.memo('availableKeys', () => this._variables.map((_v) => _v.key));
}
/**
* Gets the dependency scopes.
*/
get depScopes(): Scope[] {
return this.scope.depScopes;
}
/**
* Retrieves a variable field by its key path from the available variables.
* @param keyPath The key path to the variable field.
* @returns The found `BaseVariableField` or `undefined`.
*/
getByKeyPath(keyPath: string[] = []): BaseVariableField | undefined {
// Check if the variable is accessible in the current scope.
if (!this.variableKeys.includes(keyPath[0])) {
return;
}
return this.globalVariableTable.getByKeyPath(keyPath);
}
/**
* Tracks changes to a variable field by its key path.
* This includes changes to its type, value, or any nested properties.
* @param keyPath The key path to the variable field to track.
* @param cb The callback to execute when the variable changes.
* @param opts Configuration options for the subscription.
* @returns A disposable to unsubscribe from the tracking.
*/
trackByKeyPath<Data = BaseVariableField | undefined>(
keyPath: string[] = [],
cb: (variable?: Data) => void,
opts?: SubscribeConfig<BaseVariableField | undefined, Data>
): Disposable {
const { triggerOnInit = true, debounceAnimation, selector } = opts || {};
return subsToDisposable(
merge(this.anyVariableChange$, this.variables$)
.pipe(
triggerOnInit ? startWith() : tap(() => null),
map(() => {
const v = this.getByKeyPath(keyPath);
return selector ? selector(v) : (v as any);
}),
distinctUntilChanged(
(a, b) => shallowEqual(a, b),
(value) => {
if (value instanceof ASTNode) {
// If the value is an ASTNode, compare its hash for changes.
return value.hash;
}
return value;
}
),
// Debounce updates to a single emission per animation frame.
debounceAnimation ? debounceTime(0, animationFrameScheduler) : tap(() => null)
)
.subscribe(cb)
);
}
}
@@ -0,0 +1,67 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Subject, filter } from 'rxjs';
import { Disposable } from '@flowgram.ai/utils';
import { type Scope } from '../scope';
import { subsToDisposable } from '../../utils/toDisposable';
import { type GlobalEventActionType } from '../../ast';
type Observer<ActionType extends GlobalEventActionType = GlobalEventActionType> = (
action: ActionType
) => void;
/**
* Manages global events within a scope.
*/
export class ScopeEventData {
event$: Subject<GlobalEventActionType> = new Subject<GlobalEventActionType>();
/**
* Dispatches a global event.
* @param action The event action to dispatch.
*/
dispatch<ActionType extends GlobalEventActionType = GlobalEventActionType>(action: ActionType) {
if (this.scope.disposed) {
return;
}
this.event$.next(action);
}
/**
* Subscribes to all global events.
* @param observer The observer function to call with the event action.
* @returns A disposable to unsubscribe from the events.
*/
subscribe<ActionType extends GlobalEventActionType = GlobalEventActionType>(
observer: Observer<ActionType>
): Disposable {
return subsToDisposable(this.event$.subscribe(observer as Observer));
}
/**
* Subscribes to a specific type of global event.
* @param type The type of the event to subscribe to.
* @param observer The observer function to call with the event action.
* @returns A disposable to unsubscribe from the event.
*/
on<ActionType extends GlobalEventActionType = GlobalEventActionType>(
type: ActionType['type'],
observer: Observer<ActionType>
): Disposable {
return subsToDisposable(
this.event$.pipe(filter((_action) => _action.type === type)).subscribe(observer as Observer)
);
}
constructor(public readonly scope: Scope) {
scope.toDispose.pushAll([
this.subscribe((_action) => {
scope.variableEngine.fireGlobalEvent(_action);
}),
]);
}
}
@@ -0,0 +1,151 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { VariableTable } from '../variable-table';
import { IVariableTable } from '../types';
import { type Scope } from '../scope';
import { type VariableEngine } from '../../variable-engine';
import { createMemo } from '../../utils/memo';
import { NewASTAction } from '../../ast/types';
import { DisposeASTAction } from '../../ast/types';
import { ReSortVariableDeclarationsAction } from '../../ast/declaration/variable-declaration';
import { ASTKind, type VariableDeclaration } from '../../ast';
/**
* Manages the output variables of a scope.
*/
export class ScopeOutputData {
protected variableTable: IVariableTable;
protected memo = createMemo();
/**
* The variable engine instance.
*/
get variableEngine(): VariableEngine {
return this.scope.variableEngine;
}
/**
* The global variable table from the variable engine.
*/
get globalVariableTable(): IVariableTable {
return this.scope.variableEngine.globalVariableTable;
}
/**
* The current version of the output data, which increments on each change.
*/
get version() {
return this.variableTable.version;
}
/**
* @deprecated use onListOrAnyVarChange instead
*/
get onDataChange() {
return this.variableTable.onDataChange.bind(this.variableTable);
}
/**
* An event that fires when the list of output variables changes.
*/
get onVariableListChange() {
return this.variableTable.onVariableListChange.bind(this.variableTable);
}
/**
* An event that fires when any output variable's value changes.
*/
get onAnyVariableChange() {
return this.variableTable.onAnyVariableChange.bind(this.variableTable);
}
/**
* An event that fires when the output variable list changes or any variable's value is updated.
*/
get onListOrAnyVarChange() {
return this.variableTable.onListOrAnyVarChange.bind(this.variableTable);
}
protected _hasChanges = false;
constructor(public readonly scope: Scope) {
// Setup scope variable table based on globalVariableTable
this.variableTable = new VariableTable(scope.variableEngine.globalVariableTable);
this.scope.toDispose.pushAll([
// When the root AST node is updated, check if there are any changes.
this.scope.ast.subscribe(() => {
if (this._hasChanges) {
this.memo.clear();
this.notifyCoversChange();
this.variableTable.fireChange();
this._hasChanges = false;
}
}),
this.scope.event.on<DisposeASTAction>('DisposeAST', (_action) => {
if (_action.ast?.kind === ASTKind.VariableDeclaration) {
this.removeVariableFromTable(_action.ast.key);
}
}),
this.scope.event.on<NewASTAction>('NewAST', (_action) => {
if (_action.ast?.kind === ASTKind.VariableDeclaration) {
this.addVariableToTable(_action.ast as VariableDeclaration);
}
}),
this.scope.event.on<ReSortVariableDeclarationsAction>('ReSortVariableDeclarations', () => {
this._hasChanges = true;
}),
this.variableTable,
]);
}
/**
* The output variable declarations of the scope, sorted by order.
*/
get variables(): VariableDeclaration[] {
return this.memo('variables', () =>
this.variableTable.variables.sort((a, b) => a.order - b.order)
);
}
/**
* The keys of the output variables.
*/
get variableKeys(): string[] {
return this.memo('variableKeys', () => this.variableTable.variableKeys);
}
protected addVariableToTable(variable: VariableDeclaration) {
if (variable.scope !== this.scope) {
throw Error('VariableDeclaration must be a ast node in scope');
}
(this.variableTable as VariableTable).addVariableToTable(variable);
this._hasChanges = true;
}
protected removeVariableFromTable(key: string) {
(this.variableTable as VariableTable).removeVariableFromTable(key);
this._hasChanges = true;
}
/**
* Retrieves a variable declaration by its key.
* @param key The key of the variable.
* @returns The `VariableDeclaration` or `undefined` if not found.
*/
getVariableByKey(key: string) {
return this.variableTable.getVariableByKey(key);
}
/**
* Notifies the covering scopes that the available variables have changed.
*/
notifyCoversChange(): void {
this.scope.coverScopes.forEach((scope) => scope.available.refresh());
}
}
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { ScopeChain } from './scope-chain';
export { Scope } from './scope';
export { ScopeOutputData } from './datas';
export { type IVariableTable } from './types';
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { inject, injectable } from 'inversify';
import { DisposableCollection, type Event } from '@flowgram.ai/utils';
import { VariableEngineProvider } from '../providers';
import { type Scope } from './scope';
/**
* Manages the dependency relationships between scopes.
* This is an abstract class, and specific implementations determine how the scope order is managed.
*/
@injectable()
export abstract class ScopeChain {
readonly toDispose: DisposableCollection = new DisposableCollection();
@inject(VariableEngineProvider) variableEngineProvider: VariableEngineProvider;
get variableEngine() {
return this.variableEngineProvider();
}
constructor() {}
/**
* Refreshes the dependency and coverage relationships for all scopes.
*/
refreshAllChange(): void {
this.variableEngine.getAllScopes().forEach((_scope) => {
_scope.refreshCovers();
_scope.refreshDeps();
});
}
/**
* Gets the dependency scopes for a given scope.
* @param scope The scope to get dependencies for.
* @returns An array of dependency scopes.
*/
abstract getDeps(scope: Scope): Scope[];
/**
* Gets the covering scopes for a given scope.
* @param scope The scope to get covers for.
* @returns An array of covering scopes.
*/
abstract getCovers(scope: Scope): Scope[];
/**
* Sorts all scopes based on their dependency relationships.
* @returns A sorted array of all scopes.
*/
abstract sortAll(): Scope[];
dispose(): void {
this.toDispose.dispose();
}
get disposed(): boolean {
return this.toDispose.disposed;
}
get onDispose(): Event<void> {
return this.toDispose.onDispose;
}
}
@@ -0,0 +1,200 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { DisposableCollection } from '@flowgram.ai/utils';
import { type VariableEngine } from '../variable-engine';
import { createMemo } from '../utils/memo';
import { ASTKind, type ASTNode, type ASTNodeJSON, MapNode } from '../ast';
import { ScopeAvailableData, ScopeEventData, ScopeOutputData } from './datas';
/**
* Interface for the Scope constructor.
*/
export interface IScopeConstructor {
new (options: {
id: string | symbol;
variableEngine: VariableEngine;
meta?: Record<string, any>;
}): Scope;
}
/**
* Represents a variable scope, which manages its own set of variables and their lifecycle.
* - `scope.output` represents the variables declared within this scope.
* - `scope.available` represents all variables accessible from this scope, including those from parent scopes.
*/
export class Scope<ScopeMeta extends Record<string, any> = Record<string, any>> {
/**
* A unique identifier for the scope.
*/
readonly id: string | symbol;
/**
* The variable engine instance this scope belongs to.
*/
readonly variableEngine: VariableEngine;
/**
* Metadata associated with the scope, which can be extended by higher-level business logic.
*/
readonly meta: ScopeMeta;
/**
* The root AST node for this scope, which is a MapNode.
* It stores various data related to the scope, such as `outputs`.
*/
readonly ast: MapNode;
/**
* Manages the available variables for this scope.
*/
readonly available: ScopeAvailableData;
/**
* Manages the output variables for this scope.
*/
readonly output: ScopeOutputData;
/**
* Manages event dispatching and handling for this scope.
*/
readonly event: ScopeEventData;
/**
* A memoization utility for caching computed values.
*/
protected memo = createMemo();
public toDispose: DisposableCollection = new DisposableCollection();
constructor(options: { id: string | symbol; variableEngine: VariableEngine; meta?: ScopeMeta }) {
this.id = options.id;
this.meta = options.meta || ({} as any);
this.variableEngine = options.variableEngine;
this.event = new ScopeEventData(this);
this.ast = this.variableEngine.astRegisters.createAST(
{
kind: ASTKind.MapNode,
key: String(this.id),
},
{
scope: this,
}
) as MapNode;
this.output = new ScopeOutputData(this);
this.available = new ScopeAvailableData(this);
}
/**
* Refreshes the covering scopes.
*/
refreshCovers(): void {
this.memo.clear('covers');
}
/**
* Refreshes the dependency scopes and the available variables.
*/
refreshDeps(): void {
this.memo.clear('deps');
this.available.refresh();
}
/**
* Gets the scopes that this scope depends on.
*/
get depScopes(): Scope[] {
return this.memo('deps', () =>
this.variableEngine.chain
.getDeps(this)
.filter((_scope) => Boolean(_scope) && !_scope?.disposed)
);
}
/**
* Gets the scopes that are covered by this scope.
*/
get coverScopes(): Scope[] {
return this.memo('covers', () =>
this.variableEngine.chain
.getCovers(this)
.filter((_scope) => Boolean(_scope) && !_scope?.disposed)
);
}
/**
* Disposes of the scope and its resources.
* This will also trigger updates in dependent and covering scopes.
*/
dispose(): void {
this.ast.dispose();
this.toDispose.dispose();
// When a scope is disposed, update its dependent and covering scopes.
this.coverScopes.forEach((_scope) => _scope.refreshDeps());
this.depScopes.forEach((_scope) => _scope.refreshCovers());
}
onDispose = this.toDispose.onDispose;
get disposed(): boolean {
return this.toDispose.disposed;
}
/**
* Sets a variable in the scope with the default key 'outputs'.
*
* @param json The JSON representation of the AST node to set.
* @returns The created or updated AST node.
*/
public setVar<Node extends ASTNode = ASTNode>(json: ASTNodeJSON): Node;
/**
* Sets a variable in the scope with a specified key.
*
* @param key The key of the variable to set.
* @param json The JSON representation of the AST node to set.
* @returns The created or updated AST node.
*/
public setVar<Node extends ASTNode = ASTNode>(key: string, json: ASTNodeJSON): Node;
public setVar<Node extends ASTNode = ASTNode>(
arg1: string | ASTNodeJSON,
arg2?: ASTNodeJSON
): Node {
if (typeof arg1 === 'string' && arg2 !== undefined) {
return this.ast.set(arg1, arg2);
}
if (typeof arg1 === 'object' && arg2 === undefined) {
return this.ast.set('outputs', arg1);
}
throw new Error('Invalid arguments');
}
/**
* Retrieves a variable from the scope by its key.
*
* @param key The key of the variable to retrieve. Defaults to 'outputs'.
* @returns The AST node for the variable, or `undefined` if not found.
*/
public getVar<Node extends ASTNode = ASTNode>(key: string = 'outputs') {
return this.ast.get<Node>(key);
}
/**
* Clears a variable from the scope by its key.
*
* @param key The key of the variable to clear. Defaults to 'outputs'.
*/
public clearVar(key: string = 'outputs') {
return this.ast.remove(key);
}
}
@@ -0,0 +1,102 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Event, Disposable } from '@flowgram.ai/utils';
import { BaseVariableField, VariableDeclaration } from '../ast';
import { type Scope } from './scope';
/**
* Parameters for getting all scopes.
*/
export interface GetAllScopeParams {
/**
* Whether to sort the scopes.
*/
sort?: boolean;
}
/**
* Action type for scope changes.
*/
export interface ScopeChangeAction {
type: 'add' | 'delete' | 'update' | 'available';
scope: Scope;
}
/**
* Interface for a variable table.
*/
export interface IVariableTable extends Disposable {
/**
* The parent variable table.
*/
parentTable?: IVariableTable;
/**
* @deprecated Use `onVariableListChange` or `onAnyVariableChange` instead.
*/
onDataChange: Event<void>;
/**
* The current version of the variable table.
*/
version: number;
/**
* The list of variables in the table.
*/
variables: VariableDeclaration[];
/**
* The keys of the variables in the table.
*/
variableKeys: string[];
/**
* Fires a change event.
*/
fireChange(): void;
/**
* Gets a variable or property by its key path.
* @param keyPath The key path to the variable or property.
* @returns The found `BaseVariableField` or `undefined`.
*/
getByKeyPath(keyPath: string[]): BaseVariableField | undefined;
/**
* Gets a variable by its key.
* @param key The key of the variable.
* @returns The found `VariableDeclaration` or `undefined`.
*/
getVariableByKey(key: string): VariableDeclaration | undefined;
/**
* Disposes the variable table.
*/
dispose(): void;
/**
* Subscribes to changes in the variable list.
* @param observer The observer function.
* @returns A disposable to unsubscribe.
*/
onVariableListChange(observer: (variables: VariableDeclaration[]) => void): Disposable;
/**
* Subscribes to changes in any variable's value.
* @param observer The observer function.
* @returns A disposable to unsubscribe.
*/
onAnyVariableChange(observer: (changedVariable: VariableDeclaration) => void): Disposable;
/**
* Subscribes to both variable list changes and any variable's value changes.
* @param observer The observer function.
* @returns A disposable to unsubscribe.
*/
onListOrAnyVarChange(observer: () => void): Disposable;
}
@@ -0,0 +1,203 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Observable, Subject, merge, share, skip, switchMap } from 'rxjs';
import { DisposableCollection, Emitter } from '@flowgram.ai/utils';
import { subsToDisposable } from '../utils/toDisposable';
import { BaseVariableField } from '../ast/declaration/base-variable-field';
import { VariableDeclaration } from '../ast';
import { IVariableTable } from './types';
/**
* A class that stores and manages variables in a table-like structure.
* It provides methods for adding, removing, and retrieving variables, as well as
* observables for listening to changes in the variable list and individual variables.
*/
export class VariableTable implements IVariableTable {
protected table: Map<string, VariableDeclaration> = new Map();
toDispose = new DisposableCollection();
/**
* @deprecated
*/
protected onDataChangeEmitter = new Emitter<void>();
protected variables$: Subject<VariableDeclaration[]> = new Subject<VariableDeclaration[]>();
/**
* An observable that listens for value changes on any variable within the table.
*/
protected anyVariableChange$: Observable<VariableDeclaration> = this.variables$.pipe(
switchMap((_variables) =>
merge(
..._variables.map((_v) =>
_v.value$.pipe<any>(
// Skip the initial value of the BehaviorSubject
skip(1)
)
)
)
),
share()
);
/**
* Subscribes to updates on any variable in the list.
* @param observer A function to be called when any variable's value changes.
* @returns A disposable object to unsubscribe from the updates.
*/
onAnyVariableChange(observer: (changedVariable: VariableDeclaration) => void) {
return subsToDisposable(this.anyVariableChange$.subscribe(observer));
}
/**
* Subscribes to changes in the variable list (additions or removals).
* @param observer A function to be called when the list of variables changes.
* @returns A disposable object to unsubscribe from the updates.
*/
onVariableListChange(observer: (variables: VariableDeclaration[]) => void) {
return subsToDisposable(this.variables$.subscribe(observer));
}
/**
* Subscribes to both variable list changes and updates to any variable in the list.
* @param observer A function to be called when either the list or a variable in it changes.
* @returns A disposable collection to unsubscribe from both events.
*/
onListOrAnyVarChange(observer: () => void) {
const disposables = new DisposableCollection();
disposables.pushAll([this.onVariableListChange(observer), this.onAnyVariableChange(observer)]);
return disposables;
}
/**
* @deprecated Use onListOrAnyVarChange instead.
*/
public onDataChange = this.onDataChangeEmitter.event;
protected _version: number = 0;
/**
* Fires change events to notify listeners that the data has been updated.
*/
fireChange() {
this.bumpVersion();
this.onDataChangeEmitter.fire();
this.variables$.next(this.variables);
this.parentTable?.fireChange();
}
/**
* The current version of the variable table, incremented on each change.
*/
get version(): number {
return this._version;
}
/**
* Increments the version number, resetting to 0 if it reaches MAX_SAFE_INTEGER.
*/
protected bumpVersion() {
this._version = this._version + 1;
if (this._version === Number.MAX_SAFE_INTEGER) {
this._version = 0;
}
}
constructor(
/**
* An optional parent table. If provided, this table will contain all variables
* from the current table.
*/
public parentTable?: IVariableTable
) {
this.toDispose.pushAll([
this.onDataChangeEmitter,
// Activate the share() operator
this.onAnyVariableChange(() => {
this.bumpVersion();
}),
]);
}
/**
* An array of all variables in the table.
*/
get variables(): VariableDeclaration[] {
return Array.from(this.table.values());
}
/**
* An array of all variable keys in the table.
*/
get variableKeys(): string[] {
return Array.from(this.table.keys());
}
/**
* Retrieves a variable or a nested property field by its key path.
* @param keyPath An array of keys representing the path to the desired field.
* @returns The found variable or property field, or undefined if not found.
*/
getByKeyPath(keyPath: string[]): BaseVariableField | undefined {
const [variableKey, ...propertyKeys] = keyPath || [];
if (!variableKey) {
return;
}
const variable = this.getVariableByKey(variableKey);
return propertyKeys.length ? variable?.getByKeyPath(propertyKeys) : variable;
}
/**
* Retrieves a variable by its key.
* @param key The key of the variable to retrieve.
* @returns The variable declaration if found, otherwise undefined.
*/
getVariableByKey(key: string) {
return this.table.get(key);
}
/**
* Adds a variable to the table.
* If a parent table exists, the variable is also added to the parent.
* @param variable The variable declaration to add.
*/
addVariableToTable(variable: VariableDeclaration) {
this.table.set(variable.key, variable);
if (this.parentTable) {
(this.parentTable as VariableTable).addVariableToTable(variable);
}
}
/**
* Removes a variable from the table.
* If a parent table exists, the variable is also removed from the parent.
* @param key The key of the variable to remove.
*/
removeVariableFromTable(key: string) {
this.table.delete(key);
if (this.parentTable) {
(this.parentTable as VariableTable).removeVariableFromTable(key);
}
}
/**
* Disposes of all resources used by the variable table.
*/
dispose(): void {
this.variableKeys.forEach((_key) =>
(this.parentTable as VariableTable)?.removeVariableFromTable(_key)
);
this.parentTable?.fireChange();
this.variables$.complete();
this.variables$.unsubscribe();
this.toDispose.dispose();
}
}
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { VariableFieldKeyRenameService } from './variable-field-key-rename-service';
@@ -0,0 +1,131 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { difference } from 'lodash-es';
import { inject, injectable, postConstruct, preDestroy } from 'inversify';
import { DisposableCollection, Emitter } from '@flowgram.ai/utils';
import { VariableEngine } from '../variable-engine';
import {
ASTNode,
BaseVariableField,
ObjectPropertiesChangeAction,
VariableDeclarationListChangeAction,
} from '../ast';
interface RenameInfo {
before: BaseVariableField;
after: BaseVariableField;
}
/**
* This service is responsible for detecting when a variable field's key is renamed.
* It listens for changes in variable declaration lists and object properties, and
* determines if a change constitutes a rename operation.
*/
@injectable()
export class VariableFieldKeyRenameService {
@inject(VariableEngine) variableEngine: VariableEngine;
toDispose = new DisposableCollection();
renameEmitter = new Emitter<RenameInfo>();
/**
* Emits events for fields that are disposed of during a list change, but not renamed.
* This helps distinguish between a field that was truly removed and one that was renamed.
*/
disposeInListEmitter = new Emitter<BaseVariableField>();
/**
* An event that fires when a variable field key is successfully renamed.
*/
onRename = this.renameEmitter.event;
/**
* An event that fires when a field is removed from a list (and not part of a rename).
*/
onDisposeInList = this.disposeInListEmitter.event;
/**
* Handles changes in a list of fields to detect rename operations.
* @param ast The AST node where the change occurred.
* @param prev The list of fields before the change.
* @param next The list of fields after the change.
*/
handleFieldListChange(ast?: ASTNode, prev?: BaseVariableField[], next?: BaseVariableField[]) {
// 1. Check if a rename is possible.
if (!ast || !prev?.length || !next?.length) {
this.notifyFieldsDispose(prev, next);
return;
}
// 2. The lengths of the lists must be the same for a rename.
if (prev.length !== next.length) {
this.notifyFieldsDispose(prev, next);
return;
}
let renameNodeInfo: RenameInfo | null = null;
let existFieldChanged = false;
for (const [index, prevField] of prev.entries()) {
const nextField = next[index];
if (prevField.key !== nextField.key) {
// Only one rename is allowed at a time.
if (existFieldChanged) {
this.notifyFieldsDispose(prev, next);
return;
}
existFieldChanged = true;
if (prevField.type?.kind === nextField.type?.kind) {
renameNodeInfo = { before: prevField, after: nextField };
}
}
}
if (!renameNodeInfo) {
this.notifyFieldsDispose(prev, next);
return;
}
this.renameEmitter.fire(renameNodeInfo);
}
/**
* Notifies listeners about fields that were removed from a list.
* @param prev The list of fields before the change.
* @param next The list of fields after the change.
*/
notifyFieldsDispose(prev?: BaseVariableField[], next?: BaseVariableField[]) {
const removedFields = difference(prev || [], next || []);
removedFields.forEach((_field) => this.disposeInListEmitter.fire(_field));
}
@postConstruct()
init() {
this.toDispose.pushAll([
this.variableEngine.onGlobalEvent<VariableDeclarationListChangeAction>(
'VariableListChange',
(_action) => {
this.handleFieldListChange(_action.ast, _action.payload?.prev, _action.payload?.next);
}
),
this.variableEngine.onGlobalEvent<ObjectPropertiesChangeAction>(
'ObjectPropertiesChange',
(_action) => {
this.handleFieldListChange(_action.ast, _action.payload?.prev, _action.payload?.next);
}
),
]);
}
@preDestroy()
dispose() {
this.toDispose.dispose();
}
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
type KeyType = string | symbol;
/**
* Create memo manager
* @returns
*/
export const createMemo = (): {
<T>(key: KeyType, fn: () => T): T;
clear: (key?: KeyType) => void;
} => {
const _memoCache = new Map<KeyType, any>();
const memo = <T>(key: KeyType, fn: () => T): T => {
if (_memoCache.has(key)) {
return _memoCache.get(key) as T;
}
const data = fn();
_memoCache.set(key, data);
return data as T;
};
const clear = (key?: KeyType) => {
if (key) {
_memoCache.delete(key);
} else {
_memoCache.clear();
}
};
memo.clear = clear;
return memo;
};
@@ -0,0 +1,16 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Subscription } from 'rxjs';
import { Disposable } from '@flowgram.ai/utils';
/**
* Convert rxjs subscription to disposable
* @param subscription - The rxjs subscription
* @returns The disposable
*/
export function subsToDisposable(subscription: Subscription): Disposable {
return Disposable.create(() => subscription.unsubscribe());
}
@@ -0,0 +1,28 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ContainerModule } from 'inversify';
import { VariableEngine } from './variable-engine';
import { VariableFieldKeyRenameService } from './services';
import { ContainerProvider, VariableEngineProvider } from './providers';
import { ASTRegisters } from './ast';
/**
* An InversifyJS container module that binds all the necessary services for the variable engine.
* This module sets up the dependency injection for the core components of the variable engine.
*/
export const VariableContainerModule = new ContainerModule((bind) => {
bind(VariableEngine).toSelf().inSingletonScope();
bind(ASTRegisters).toSelf().inSingletonScope();
bind(VariableFieldKeyRenameService).toSelf().inSingletonScope();
// Provide a dynamic provider for VariableEngine to prevent circular dependencies.
bind(VariableEngineProvider).toDynamicValue((ctx) => () => ctx.container.get(VariableEngine));
// Provide a ContainerProvider to allow AST nodes and other components to access the container.
bind(ContainerProvider).toDynamicValue((ctx) => () => ctx.container);
});
@@ -0,0 +1,197 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Subject } from 'rxjs';
import { inject, injectable, interfaces, preDestroy } from 'inversify';
import { Disposable, DisposableCollection } from '@flowgram.ai/utils';
import { Emitter } from '@flowgram.ai/utils';
import { subsToDisposable } from './utils/toDisposable';
import { createMemo } from './utils/memo';
import { VariableTable } from './scope/variable-table';
import { ScopeChangeAction } from './scope/types';
import { IScopeConstructor } from './scope/scope';
import { Scope, ScopeChain, type IVariableTable } from './scope';
import { ContainerProvider } from './providers';
import { ASTRegisters, type GlobalEventActionType } from './ast';
/**
* The core of the variable engine system.
* It manages scopes, variables, and events within the system.
*/
@injectable()
export class VariableEngine implements Disposable {
protected toDispose = new DisposableCollection();
protected memo = createMemo();
protected scopeMap = new Map<string | symbol, Scope>();
/**
* A rxjs subject that emits global events occurring within the variable engine.
*/
globalEvent$: Subject<GlobalEventActionType> = new Subject<GlobalEventActionType>();
protected onScopeChangeEmitter = new Emitter<ScopeChangeAction>();
/**
* A table containing all global variables.
*/
public globalVariableTable: IVariableTable = new VariableTable();
/**
* An event that fires whenever a scope is added, updated, or deleted.
*/
public onScopeChange = this.onScopeChangeEmitter.event;
@inject(ContainerProvider) private readonly containerProvider: ContainerProvider;
/**
* The Inversify container instance.
*/
get container(): interfaces.Container {
return this.containerProvider();
}
constructor(
/**
* The scope chain, which manages the dependency relationships between scopes.
*/
@inject(ScopeChain)
public readonly chain: ScopeChain,
/**
* The registry for all AST node types.
*/
@inject(ASTRegisters)
public readonly astRegisters: ASTRegisters
) {
this.toDispose.pushAll([
chain,
Disposable.create(() => {
// Dispose all scopes
this.getAllScopes().forEach((scope) => scope.dispose());
this.globalVariableTable.dispose();
}),
]);
}
/**
* Disposes of all resources used by the variable engine.
*/
@preDestroy()
dispose(): void {
this.toDispose.dispose();
}
/**
* Retrieves a scope by its unique identifier.
* @param scopeId The ID of the scope to retrieve.
* @returns The scope if found, otherwise undefined.
*/
getScopeById(scopeId: string | symbol): Scope | undefined {
return this.scopeMap.get(scopeId);
}
/**
* Removes a scope by its unique identifier and disposes of it.
* @param scopeId The ID of the scope to remove.
*/
removeScopeById(scopeId: string | symbol): void {
this.getScopeById(scopeId)?.dispose();
}
/**
* Creates a new scope or retrieves an existing one if the ID and type match.
* @param id The unique identifier for the scope.
* @param meta Optional metadata for the scope, defined by the user.
* @param options Options for creating the scope.
* @param options.ScopeConstructor The constructor to use for creating the scope. Defaults to `Scope`.
* @returns The created or existing scope.
*/
createScope(
id: string | symbol,
meta?: Record<string, any>,
options: {
ScopeConstructor?: IScopeConstructor;
} = {}
): Scope {
const { ScopeConstructor = Scope } = options;
let scope = this.getScopeById(id);
if (!scope) {
scope = new ScopeConstructor({ variableEngine: this, meta, id });
this.scopeMap.set(id, scope);
this.onScopeChangeEmitter.fire({ type: 'add', scope: scope! });
scope.toDispose.pushAll([
scope.ast.subscribe(() => {
this.onScopeChangeEmitter.fire({ type: 'update', scope: scope! });
}),
// Fires when available variables change
scope.available.onDataChange(() => {
this.onScopeChangeEmitter.fire({ type: 'available', scope: scope! });
}),
]);
scope.onDispose(() => {
this.scopeMap.delete(id);
this.onScopeChangeEmitter.fire({ type: 'delete', scope: scope! });
});
}
return scope;
}
/**
* Retrieves all scopes currently managed by the engine.
* @param options Options for retrieving the scopes.
* @param options.sort Whether to sort the scopes based on their dependency chain.
* @returns An array of all scopes.
*/
getAllScopes({
sort,
}: {
sort?: boolean;
} = {}): Scope[] {
const allScopes = Array.from(this.scopeMap.values());
if (sort) {
const sortScopes = this.chain.sortAll();
const remainScopes = new Set(allScopes);
sortScopes.forEach((_scope) => remainScopes.delete(_scope));
return [...sortScopes, ...Array.from(remainScopes)];
}
return [...allScopes];
}
/**
* Fires a global event to be broadcast to all listeners.
* @param event The global event to fire.
*/
fireGlobalEvent(event: GlobalEventActionType) {
this.globalEvent$.next(event);
}
/**
* Subscribes to a specific type of global event.
* @param type The type of the event to listen for.
* @param observer A function to be called when the event is observed.
* @returns A disposable object to unsubscribe from the event.
*/
onGlobalEvent<ActionType extends GlobalEventActionType = GlobalEventActionType>(
type: ActionType['type'],
observer: (action: ActionType) => void
): Disposable {
return subsToDisposable(
this.globalEvent$.subscribe((_action) => {
if (_action.type === type) {
observer(_action as ActionType);
}
})
);
}
}
@@ -0,0 +1,11 @@
{
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
"include": [
"./src"
],
"compilerOptions": {
"types": [
"reflect-metadata"
]
}
}
@@ -0,0 +1,31 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
const path = require('path');
import { defineConfig } from 'vitest/config';
export default defineConfig({
build: {
commonjsOptions: {
transformMixedEsModules: true,
},
},
test: {
globals: true,
mockReset: false,
environment: 'jsdom',
setupFiles: [path.resolve(__dirname, './vitest.setup.ts')],
include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'],
exclude: [
'**/__mocks__/**',
'**/node_modules/**',
'**/dist/**',
'**/lib/**', // lib 编译结果忽略掉
'**/cypress/**',
'**/.{idea,git,cache,output,temp}/**',
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
],
},
});
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import 'reflect-metadata';