This commit is contained in:
@@ -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;
|
||||
+97
@@ -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,
|
||||
]
|
||||
`;
|
||||
+170
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
+97
@@ -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();
|
||||
});
|
||||
});
|
||||
+168
@@ -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);
|
||||
});
|
||||
});
|
||||
+179
@@ -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);
|
||||
});
|
||||
});
|
||||
+146
@@ -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;
|
||||
}
|
||||
+112
@@ -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;
|
||||
}
|
||||
}
|
||||
+119
@@ -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';
|
||||
+131
@@ -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';
|
||||
Reference in New Issue
Block a user