diff --git a/packages/inula-vscode-plugin/.gitignore b/packages/inula-vscode-plugin/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..0b60dfa12fb992ff3c1555855471510558356a46 --- /dev/null +++ b/packages/inula-vscode-plugin/.gitignore @@ -0,0 +1,5 @@ +out +dist +node_modules +.vscode-test/ +*.vsix diff --git a/packages/inula-vscode-plugin/.marscode/deviceInfo.json b/packages/inula-vscode-plugin/.marscode/deviceInfo.json new file mode 100644 index 0000000000000000000000000000000000000000..260700db071401de15f68a1812d8f1e4580229d8 --- /dev/null +++ b/packages/inula-vscode-plugin/.marscode/deviceInfo.json @@ -0,0 +1,3 @@ +{ + "deviceId": "3a873ae8b56a4a38bc8ef21a8a7ecdc7d5df5ac9b0bd44cfcae505e6fa5ec2a7" +} \ No newline at end of file diff --git a/packages/inula-vscode-plugin/.vscode-test.mjs b/packages/inula-vscode-plugin/.vscode-test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..b62ba25f015a10788b8e1693a5678dd90ec5511a --- /dev/null +++ b/packages/inula-vscode-plugin/.vscode-test.mjs @@ -0,0 +1,5 @@ +import { defineConfig } from '@vscode/test-cli'; + +export default defineConfig({ + files: 'out/test/**/*.test.js', +}); diff --git a/packages/inula-vscode-plugin/.vscodeignore b/packages/inula-vscode-plugin/.vscodeignore new file mode 100644 index 0000000000000000000000000000000000000000..88b1ed8497e4bb4318efecccb9e1411b5c3bbf85 --- /dev/null +++ b/packages/inula-vscode-plugin/.vscodeignore @@ -0,0 +1,15 @@ +.vscode/** +.vscode-test/** +out/** +node_modules/** +src/** +.gitignore +.yarnrc +webpack.config.js +vsc-extension-quickstart.md +**/tsconfig.json +**/eslint.config.mjs +**/*.map +**/*.ts +**/.vscode-test.* +!dist/plugin.js \ No newline at end of file diff --git a/packages/inula-vscode-plugin/CHANGELOG.md b/packages/inula-vscode-plugin/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..b9543e55ada09e91be37e2aec69e03e0974a4017 --- /dev/null +++ b/packages/inula-vscode-plugin/CHANGELOG.md @@ -0,0 +1,9 @@ +# Change Log + +All notable changes to the "openinula" extension will be documented in this file. + +Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. + +## [Unreleased] + +- Initial release \ No newline at end of file diff --git a/packages/inula-vscode-plugin/README.en.md b/packages/inula-vscode-plugin/README.en.md new file mode 100644 index 0000000000000000000000000000000000000000..6b7775f401ad3ff48e83c29dbdc2717ee3b1ffdb --- /dev/null +++ b/packages/inula-vscode-plugin/README.en.md @@ -0,0 +1,36 @@ +# inula2-vscode-plugin + +#### Description +openInula2.0的VSCode插件,能够在开发过程中为开发者提供组件信息、标识状态、响应关系等。 + +#### Software Architecture +Software architecture description + +#### Installation + +1. xxxx +2. xxxx +3. xxxx + +#### Instructions + +1. xxxx +2. xxxx +3. xxxx + +#### Contribution + +1. Fork the repository +2. Create Feat_xxx branch +3. Commit your code +4. Create Pull Request + + +#### Gitee Feature + +1. You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md +2. Gitee blog [blog.gitee.com](https://blog.gitee.com) +3. Explore open source project [https://gitee.com/explore](https://gitee.com/explore) +4. The most valuable open source project [GVP](https://gitee.com/gvp) +5. The manual of Gitee [https://gitee.com/help](https://gitee.com/help) +6. The most popular members [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/) diff --git a/packages/inula-vscode-plugin/README.md b/packages/inula-vscode-plugin/README.md new file mode 100644 index 0000000000000000000000000000000000000000..58dfdc737d6d1673822506113cdf233a50ce0bb9 --- /dev/null +++ b/packages/inula-vscode-plugin/README.md @@ -0,0 +1,108 @@ +# openinula README + +This is the README for your extension "openinula". After writing up a brief description, we recommend including the following sections. + +## Features + +Describe specific features of your extension including screenshots of your extension in action. Image paths are relative to this README file. + +For example if there is an image subfolder under your extension project workspace: + +\!\[feature X\]\(images/feature-x.png\) + +> Tip: Many popular extensions utilize animations. This is an excellent way to show off your extension! We recommend short, focused animations that are easy to follow. + +## Requirements + +If you have any requirements or dependencies, add a section describing those and how to install and configure them. + +## Extension Settings + +Include if your extension adds any VS Code settings through the `contributes.configuration` extension point. + +For example: + +This extension contributes the following settings: + +* `myExtension.enable`: Enable/disable this extension. +* `myExtension.thing`: Set to `blah` to do something. + +## Known Issues + +Calling out known issues can help limit users opening duplicate issues against your extension. + +## Release Notes + +Users appreciate release notes as you update your extension. + +### 1.0.0 + +Initial release of ... + +### 1.0.1 + +Fixed issue #. + +### 1.1.0 + +Added features X, Y, and Z. + +--- + +## Following extension guidelines + +Ensure that you've read through the extensions guidelines and follow the best practices for creating your extension. + +* [Extension Guidelines](https://code.visualstudio.com/api/references/extension-guidelines) + +## Working with Markdown + +You can author your README using Visual Studio Code. Here are some useful editor keyboard shortcuts: + +* Split the editor (`Cmd+\` on macOS or `Ctrl+\` on Windows and Linux). +* Toggle preview (`Shift+Cmd+V` on macOS or `Shift+Ctrl+V` on Windows and Linux). +* Press `Ctrl+Space` (Windows, Linux, macOS) to see a list of Markdown snippets. + +## For more information + +* [Visual Studio Code's Markdown Support](http://code.visualstudio.com/docs/languages/markdown) +* [Markdown Syntax Reference](https://help.github.com/articles/markdown-basics/) + +**Enjoy!** +# inula2-vscode-plugin + +#### 介绍 +openInula2.0的VSCode插件,能够在开发过程中为开发者提供组件信息、标识状态、响应关系等。 + +#### 软件架构 +软件架构说明 + + +#### 安装教程 + +1. xxxx +2. xxxx +3. xxxx + +#### 使用说明 + +1. xxxx +2. xxxx +3. xxxx + +#### 参与贡献 + +1. Fork 本仓库 +2. 新建 Feat_xxx 分支 +3. 提交代码 +4. 新建 Pull Request + + +#### 特技 + +1. 使用 Readme\_XXX.md 来支持不同的语言,例如 Readme\_en.md, Readme\_zh.md +2. Gitee 官方博客 [blog.gitee.com](https://blog.gitee.com) +3. 你可以 [https://gitee.com/explore](https://gitee.com/explore) 这个地址来了解 Gitee 上的优秀开源项目 +4. [GVP](https://gitee.com/gvp) 全称是 Gitee 最有价值开源项目,是综合评定出的优秀开源项目 +5. Gitee 官方提供的使用手册 [https://gitee.com/help](https://gitee.com/help) +6. Gitee 封面人物是一档用来展示 Gitee 会员风采的栏目 [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/) diff --git a/packages/inula-vscode-plugin/babel.config.js b/packages/inula-vscode-plugin/babel.config.js new file mode 100644 index 0000000000000000000000000000000000000000..7d9979abebd9bbe973c8bd53b0c8434ec68cd96b --- /dev/null +++ b/packages/inula-vscode-plugin/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['@babel/preset-env'] +}; \ No newline at end of file diff --git a/packages/inula-vscode-plugin/eslint.config.mjs b/packages/inula-vscode-plugin/eslint.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..d5c0b53a76cbb7b91da5ff128483e3f12a1351cc --- /dev/null +++ b/packages/inula-vscode-plugin/eslint.config.mjs @@ -0,0 +1,28 @@ +import typescriptEslint from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; + +export default [{ + files: ["**/*.ts"], +}, { + plugins: { + "@typescript-eslint": typescriptEslint, + }, + + languageOptions: { + parser: tsParser, + ecmaVersion: 2022, + sourceType: "module", + }, + + rules: { + "@typescript-eslint/naming-convention": ["warn", { + selector: "import", + format: ["camelCase", "PascalCase"], + }], + + curly: "warn", + eqeqeq: "warn", + "no-throw-literal": "warn", + semi: "warn", + }, +}]; \ No newline at end of file diff --git a/packages/inula-vscode-plugin/jest.config.js b/packages/inula-vscode-plugin/jest.config.js new file mode 100644 index 0000000000000000000000000000000000000000..e88e3e97d4713d84b74b053a7dabe90fde691126 --- /dev/null +++ b/packages/inula-vscode-plugin/jest.config.js @@ -0,0 +1,17 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts', '.tsx', '.js', '.jsx', '.mjs'], + globals: { + 'ts-jest': { + useESM: true, + }, + }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, +}; \ No newline at end of file diff --git a/packages/inula-vscode-plugin/package.json b/packages/inula-vscode-plugin/package.json new file mode 100644 index 0000000000000000000000000000000000000000..f2febfa2641ef58fb9b66ea5f53cf973b0742783 --- /dev/null +++ b/packages/inula-vscode-plugin/package.json @@ -0,0 +1,107 @@ +{ + "name": "openinula", + "displayName": "openinula-vsc", + "description": "", + "version": "0.0.1", + "engines": { + "vscode": "^1.101.0" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onLanguage:javascript", + "onLanguage:javascriptreact", + "onLanguage:typescript", + "onLanguage:typescriptreact" + ], + "main": "./dist/extension.js", + "contributes": { + "commands": [ + { + "command": "openinula.analyzeFile", + "title": "Analyze Current File" + }, + { + "command": "hello world", + "title": "test" + }, + { + "command": "yourLanguageId", + "title": "showhover" + } + ], + "views": { + "explorer": [ + { + "icon": "", + "id": "inulaComponents", + "name": "Inula Components" + } + ] + }, + "languages": [ + { + "id": "javascript", + "extensions": [ + ".js", + ".jsx" + ] + }, + { + "id": "typescript", + "extensions": [ + ".ts", + ".tsx" + ] + } + ] + }, + "scripts": { + "vscode:prepublish": "npm run package", + "compile": "webpack", + "watch": "webpack --watch", + "package": "webpack --mode production --devtool hidden-source-map", + "compile-tests": "tsc -p . --outDir out", + "watch-tests": "tsc -p . -w --outDir out", + "pretest": "npm run compile-tests && npm run compile && npm run lint", + "lint": "eslint src", + "test": "vscode-test" + }, + "devDependencies": { + "@babel/cli": "^7.28.0", + "@babel/core": "^7.28.0", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/preset-env": "^7.28.0", + "@babel/preset-react": "^7.27.1", + "@babel/preset-typescript": "^7.27.1", + "@babel/register": "^7.27.1", + "@types/jest": "^30.0.0", + "@types/mocha": "^10.0.10", + "@types/node": "^20.19.8", + "@types/vscode": "^1.101.0", + "@typescript-eslint/eslint-plugin": "^8.31.1", + "@typescript-eslint/parser": "^8.31.1", + "@vscode/test-cli": "^0.0.11", + "@vscode/test-electron": "^2.5.2", + "babel-plugin-jsx": "^1.2.0", + "cross-env": "^7.0.3", + "eslint": "^9.25.1", + "jest": "^30.0.4", + "ts-jest": "^29.4.0", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "typescript": "^5.8.3", + "webpack": "^5.99.7", + "webpack-cli": "^6.0.1" + }, + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "@openinula/babel-api": "^0.0.2", + "@openinula/error-handler": "^0.0.4", + "@openinula/jsx-view-parser": "^0.0.4", + "@openinula/reactivity-parser": "^0.0.4" + } +} diff --git a/packages/inula-vscode-plugin/src/analyze/Analyzers/functionalMacroAnalyze.ts b/packages/inula-vscode-plugin/src/analyze/Analyzers/functionalMacroAnalyze.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed3f4fccdec236927069c81d0d9a87a57daed550 --- /dev/null +++ b/packages/inula-vscode-plugin/src/analyze/Analyzers/functionalMacroAnalyze.ts @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { NodePath } from '@babel/core'; +import { LifeCycle, Visitor } from '../types'; +import { types as t } from '@openinula/babel-api'; +import { DID_MOUNT, DID_UNMOUNT, WATCH, WILL_MOUNT, WILL_UNMOUNT } from '../../constants'; +import { extractFnFromMacro } from '../../utils'; + +function isLifeCycleName(name: string): name is LifeCycle { + return [WILL_MOUNT, DID_MOUNT, WILL_UNMOUNT, DID_UNMOUNT].includes(name); +} + +/** + * Analyze the functional macro in the function component + * 1. lifecycle + * 1. willMount + * 2. didMount + * 3. willUnMount + * 4. didUnmount + * 2. watch + */ +export function functionalMacroAnalyze(): Visitor { + return { + ExpressionStatement(path: NodePath, { builder }) { + const expression = path.get('expression'); + if (expression.isCallExpression()) { + const callee = expression.get('callee'); + if (callee.isIdentifier()) { + const calleeName = callee.node.name; + // lifecycle + if (isLifeCycleName(calleeName)) { + const fnPath = extractFnFromMacro(expression, calleeName); + builder.addLifecycle(calleeName, fnPath); + return; + } + + // watch + if (calleeName === WATCH) { + const fnPath = extractFnFromMacro(expression, WATCH); + const depsPath = getWatchDeps(expression); + + const dependency = builder.getDependency((depsPath ?? fnPath).node); + builder.addWatch(fnPath, dependency); + return; + } + } + } + + builder.addRawStmt(path.node); + }, + }; +} + +function getWatchDeps(callExpression: NodePath) { + const args = callExpression.get('arguments'); + if (!args[1]) { + return null; + } + + let deps: null | NodePath = null; + if (args[1].isArrayExpression()) { + deps = args[1]; + } else { + console.error('watch deps should be an array expression'); + } + return deps; +} diff --git a/packages/inula-vscode-plugin/src/analyze/Analyzers/hookAnalyze.ts b/packages/inula-vscode-plugin/src/analyze/Analyzers/hookAnalyze.ts new file mode 100644 index 0000000000000000000000000000000000000000..aeb651cf18370814c2773f420aba64afb55c5f78 --- /dev/null +++ b/packages/inula-vscode-plugin/src/analyze/Analyzers/hookAnalyze.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { Visitor } from '../types'; +import { type NodePath } from '@babel/core'; +import { types as t } from '@openinula/babel-api'; + +/** + * Analyze the return in the hook + */ +export function hookReturnAnalyze(): Visitor { + return { + ReturnStatement(path: NodePath, { builder }) { + const returnedNode = path.node.argument; + if (returnedNode) { + builder.setReturnValue(returnedNode); + } + }, + }; +} diff --git a/packages/inula-vscode-plugin/src/analyze/Analyzers/propsAnalyze.ts b/packages/inula-vscode-plugin/src/analyze/Analyzers/propsAnalyze.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea0f91b28681f9b13e87ba1d429fccba5b61e36b --- /dev/null +++ b/packages/inula-vscode-plugin/src/analyze/Analyzers/propsAnalyze.ts @@ -0,0 +1,44 @@ +import type { NodePath } from '@babel/core'; +import { types as t } from '@openinula/babel-api'; +import { AnalyzeContext, Visitor } from '../types'; +import { DestructuringPayload, parseDestructuring } from '../parseDestructuring'; +import { CompilerError } from '@openinula/error-handler'; + +export function compPropsAnalyze(): Visitor { + return { + Props: (path: NodePath[], { builder }: AnalyzeContext) => { + const props = path[0]; + const reducer = (payload: DestructuringPayload) => { + if (payload.type === 'rest') { + builder.addRestProps(payload.name); + } else if (payload.type === 'single') { + builder.addSingleProp(payload.name, payload.value); + } else if (payload.type === 'props') { + builder.addProps(payload.name, payload.node); + } + }; + parseDestructuring(props, reducer); + }, + }; +} + +export function hookPropsAnalyze(): Visitor { + return { + Props: (path: NodePath[], { builder }: AnalyzeContext) => { + path.forEach((prop, idx) => { + if (prop.isIdentifier()) { + builder.addSingleProp(idx, prop); + } else if (prop.isRestElement()) { + const arg = prop.get('argument'); + if (!Array.isArray(arg) && arg.isIdentifier()) { + builder.addRestProps(arg.node.name); + return; + } + throw new CompilerError('Unsupported rest element type in hook props destructuring', prop.node.loc); + } else { + builder.addSingleProp(idx, prop); + } + }); + }, + }; +} diff --git a/packages/inula-vscode-plugin/src/analyze/Analyzers/variablesAnalyze.ts b/packages/inula-vscode-plugin/src/analyze/Analyzers/variablesAnalyze.ts new file mode 100644 index 0000000000000000000000000000000000000000..d28971457a7ff2c8b8e2d47f1000d2bb82766e53 --- /dev/null +++ b/packages/inula-vscode-plugin/src/analyze/Analyzers/variablesAnalyze.ts @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { AnalyzeContext, CTX_PROPS, Visitor } from '../types'; +import { isStaticValue, isValidPath } from '../utils'; +import { type NodePath } from '@babel/core'; +import { importMap } from '../../constants'; +import { analyzeUnitOfWork } from '../index'; +import { types as t } from '@openinula/babel-api'; +import { assertIdOrDeconstruct, isCompPath, isUseContext } from '../../utils'; +import { IRBuilder } from '../IRBuilder'; +import { parseDestructuring } from '../parseDestructuring'; +import { CompilerError } from '@openinula/error-handler'; + +/** + * collect all properties and methods from the node + * and analyze the dependencies of the properties + * @returns + */ +export function variablesAnalyze(): Visitor { + return { + VariableDeclaration(path: NodePath, ctx) { + const { builder } = ctx; + const declarations = path.get('declarations'); + // iterate the declarations + declarations.forEach(declaration => { + const id = declaration.get('id'); + // --- properties: the state / computed / plain properties / methods --- + const init = declaration.get('init'); + const kind = path.node.kind; + + // Check if the variable can't be modified + if (kind === 'const' && isStaticValue(init.node)) { + builder.addRawStmt(path.node); + return; + } + + if (!isValidPath(init)) { + assertIdentifier(id); + resolveUninitializedVariable(kind, builder, id, declaration.node); + return; + } + if (t.isLVal(id.node)) { + assertIdOrDeconstruct(id as NodePath, 'Invalid Variable type when adding variable: ' + id.type); + } else { + throw new CompilerError('Unsupported pattern for variable declaration', id.node.loc); + } + + // Handle the subcomponent, should like Component(() => {}) + if (init.isCallExpression() && isCompPath(init)) { + assertIdentifier(id); + resolveSubComponent(init, builder, id, ctx); + return; + } + + // Handle use context, like const ctx = useContext() + /* if (init.isCallExpression() && isUseContext(init)) { + const context = init.get('arguments')[0]; + assertIdentifier(context); + builder.addContext(id, context.node); + parseDestructuring(id, payload => { + if (payload.type === 'rest') { + builder.addRestProps(payload.name, CTX_PROPS, context.node.name); + } else if (payload.type === 'single') { + builder.addSingleProp(payload.name, payload.value, CTX_PROPS, context.node.name); + } else if (payload.type === 'props') { + builder.addProps(payload.name, payload.node, CTX_PROPS, context.node.name); + } + }); + return; + } + + // ensure evert jsx slice call expression can found responding sub-component + // assertJSXSliceIsValid(path, builder.checkSubComponent.bind(builder)); + + builder.addVariable({ + id, + value: init.node, + kind: path.node.kind, + node: declaration.node, + }); */ + }); + }, + FunctionDeclaration(path: NodePath, { builder }) { + builder.addRawStmt(path.node); + }, + }; +} + +function assertIdentifier(id: NodePath): asserts id is NodePath { + if (!id.isIdentifier()) { + throw new CompilerError(`${id.node.type} is not valid initial value type for state`, id.node.loc); + } +} + +function assertJSXSliceIsValid(path: NodePath, checker: (name: string) => boolean) { + path.traverse({ + CallExpression(callPath) { + const callee = callPath.node.callee; + if (t.isIdentifier(callee) && callee.name === importMap.createCompNode) { + const subCompIdPath = callPath.get('arguments')[0]; + if (!subCompIdPath.isIdentifier()) { + throw new CompilerError('invalid jsx slice', subCompIdPath.node.loc); + } + const subCompName = subCompIdPath.node.name; + if (!checker(subCompName)) { + throw new CompilerError(`Sub component not found: ${subCompName}`, subCompIdPath.node.loc); + } + } + }, + }); +} + +function resolveUninitializedVariable( + kind: 'var' | 'let' | 'const' | 'using' | 'await using', + builder: IRBuilder, + id: NodePath, + node: Array[number] +) { + if (kind === 'const') { + builder.addRawStmt(t.variableDeclaration('const', [node])); + } + builder.addVariable({ + id, + value: null, + node, + kind, + }); +} + +function resolveSubComponent( + init: NodePath, + builder: IRBuilder, + id: NodePath, + ctx: AnalyzeContext +) { + const fnNode = init.get('arguments')[0] as NodePath | NodePath; + + builder.startSubComponent(id.node.name, fnNode); + + analyzeUnitOfWork(id.node.name, fnNode, ctx); + + builder.endSubComponent(); +} diff --git a/packages/inula-vscode-plugin/src/analyze/Analyzers/viewAnalyze.ts b/packages/inula-vscode-plugin/src/analyze/Analyzers/viewAnalyze.ts new file mode 100644 index 0000000000000000000000000000000000000000..109cd92e71b82257e7b238e78288cbc08b0b28f4 --- /dev/null +++ b/packages/inula-vscode-plugin/src/analyze/Analyzers/viewAnalyze.ts @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { Visitor } from '../types'; +import { type NodePath } from '@babel/core'; +import { types as t, traverse } from '@openinula/babel-api'; + +/** + * Analyze the watch in the function component + */ +export function viewAnalyze(): Visitor { + return { + ReturnStatement(path: NodePath, { builder }) { + const returnedPath = path.get('argument'); + if (returnedPath.isJSXElement() || returnedPath.isJSXFragment()) { + builder.setViewChild(returnedPath.node); + } else if (returnedPath.isExpression() && hasVariables(returnedPath.node)) { + // If the return value is an expression and contains variables, wrap it in a JSX expression container + builder.setViewChild(t.jsxExpressionContainer(returnedPath.node)); + } else if (returnedPath.isStringLiteral() || returnedPath.isNumericLiteral() || returnedPath.isBooleanLiteral()) { + // If the return value is a string, number, or boolean, wrap it in a JSX text node + builder.setViewChild(t.jsxText(returnedPath.node.value.toString())); + } else if (returnedPath.node === null || returnedPath.isNullLiteral()) { + builder.setEmptyView(); + } + }, + }; +} + +function hasVariables(expression: t.Expression): boolean { + let hasVar = false; + + // 创建访问者对象 + const visitor = { + Identifier(path: NodePath) { + // 检查这个标识符是否是一个变量引用 + // isReferencedIdentifier 方法检查标识符是否作为引用使用 + if (path.isReferencedIdentifier()) { + hasVar = true; + // 一旦找到变量就停止遍历 + path.stop(); + } + }, + }; + + // 遍历表达式AST + traverse(t.file(t.program([t.expressionStatement(expression)])), visitor); + + return hasVar; +} diff --git a/packages/inula-vscode-plugin/src/analyze/IRBuilder.ts b/packages/inula-vscode-plugin/src/analyze/IRBuilder.ts new file mode 100644 index 0000000000000000000000000000000000000000..61b724705c97d7726f42dcade43dbd8877960d2f --- /dev/null +++ b/packages/inula-vscode-plugin/src/analyze/IRBuilder.ts @@ -0,0 +1,502 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { + BaseVariable, + ComponentNode, + CompOrHook, + DerivedSource, + DerivedStmt, + FunctionalExpression, + HookNode, + IRBlock, + IRStmt, + LifeCycle, + PARAM_PROPS, + PropsSource, + RestPropStmt, + SinglePropStmt, + StateStmt, + WholePropStmt, +} from './types'; +import { createIRNode } from './nodeFactory'; +import type { NodePath } from '@babel/core'; +import { getBabelApi, types as t } from '@openinula/babel-api'; +import { COMPONENT, isPropStmt, PropType, reactivityFuncNames } from '../constants'; +import { Dependency, getDependenciesFromNode, parseReactivity } from '@openinula/reactivity-parser'; +import { assertComponentNode, assertHookNode, isUseHook } from './utils'; +import { AllowedJSXNode, parseView as parseJSX } from '@openinula/jsx-view-parser'; +import { pruneUnusedState } from './pruneUnusedState'; +import { assertIdOrDeconstruct, bitmapToIndices } from '../utils'; +import { CompilerError } from '@openinula/error-handler'; + +function trackSource(waveBitsMap: Map, stmt: DerivedStmt, ownBit: number) { + // Then, we need to find the wave bits(other derived reactive dependency on it) of the derived reactive id + const downstreamWaveBits = waveBitsMap.get(stmt.reactiveId); + + const derivedWaves = downstreamWaveBits ? downstreamWaveBits | ownBit : ownBit; + + // At last, add the derived wave bit to the source + if (stmt.dependency) { + bitmapToIndices(stmt.dependency.depIdBitmap).forEach(id => { + const waveBits = waveBitsMap.get(id); + if (waveBits) { + waveBitsMap.set(id, waveBits | derivedWaves); + } else { + waveBitsMap.set(id, derivedWaves); + } + }); + } +} + +function getWaveBits( + idToWaveBitMap: Map, + stmt: StateStmt | DerivedStmt | SinglePropStmt | RestPropStmt | WholePropStmt, + waveBitsMap: Map +) { + const ownBit = idToWaveBitMap.get(stmt.reactiveId); + let waveBits = ownBit; + if (ownBit) { + // if ownBit exist, means the state was used. Try to find derivedState using the state + const downstreamWaveBits = waveBitsMap.get(stmt.reactiveId) ?? 0; + waveBits = ownBit | downstreamWaveBits; + } + return waveBits; +} + +export class IRBuilder { + #current: HookNode | ComponentNode; + readonly #htmlTags: string[]; + reactiveIndex = 0; + + constructor(name: string, type: CompOrHook, fnNode: NodePath, htmlTags: string[]) { + this.#current = createIRNode(name, type, fnNode); + this.#htmlTags = htmlTags; + } + + getNextId() { + return 1 << this.reactiveIndex++; + } + + addStmt(stmt: IRStmt) { + this.#current.body.push(stmt); + } + + addDeclaredReactive(name: string, id?: number) { + const reactiveId = id ?? this.getNextId(); + this.#current.scope.reactiveMap.set(name, reactiveId); + return reactiveId; + } + + /** + * Get tree level global reactive map + */ + getGlobalReactiveMap() { + const fullReactiveMap = new Map(this.#current.scope.reactiveMap); + let next = this.#current.parent; + while (next) { + next.scope.reactiveMap.forEach((id, name) => { + if (!fullReactiveMap.has(name)) { + fullReactiveMap.set(name, id); + } + }); + next = next.parent; + } + + return fullReactiveMap; + } + + getDependency = (node: t.Expression | t.Statement) => { + return getDependenciesFromNode(node, this.getGlobalReactiveMap(), reactivityFuncNames); + }; + + addRawStmt(stmt: t.Statement) { + this.addStmt({ + type: 'raw', + value: stmt, + }); + } + + addProps(name: string, value: t.Identifier, source: PropsSource = PARAM_PROPS, ctxName?: string) { + const reactiveId = this.addDeclaredReactive(name); + this.addStmt({ + name, + value, + type: PropType.WHOLE, + reactiveId, + source, + ctxName, + }); + } + + addRestProps(name: string, source: PropsSource = PARAM_PROPS, ctxName?: string) { + // check if the props is initialized + const reactiveId = this.addDeclaredReactive(name); + this.addStmt({ + name, + type: PropType.REST, + reactiveId, + source, + ctxName, + }); + } + + addSingleProp( + key: string | number, + valPath: NodePath, + source: PropsSource = PARAM_PROPS, + ctxName?: string + ) { + if (!valPath.isLVal()) { + throw new CompilerError('Invalid Prop Value type: ' + valPath.type, valPath.node.loc); + } + const reactiveId = this.getNextId(); + const destructured = getDestructure(valPath); + let value = valPath.node; + let defaultValue: t.Expression | null = null; + if (destructured) { + const destructuredNames = searchNestedProps(destructured); + + // All destructured names share the same id + destructuredNames.forEach(name => this.addDeclaredReactive(name, reactiveId)); + } else { + let propName = key; + // alias + if (valPath.isIdentifier() && valPath.node.name !== key) { + propName = valPath.node.name; + } + if (valPath.isAssignmentPattern()) { + const left = valPath.node.left; + if (t.isIdentifier(left) && left.name !== key) { + propName = left.name; + } + value = left; + defaultValue = valPath.node.right; + } + this.addDeclaredReactive(propName as string, reactiveId); + } + this.addStmt({ + name: key, + reactiveId, + value, + type: PropType.SINGLE, + isDestructured: !!destructured, + defaultValue, + source, + ctxName, + }); + } + + addVariable(varInfo: BaseVariable) { + const id = varInfo.id; + const reactiveId = this.getNextId(); + const varIds = this.parseIdInLVal(id, reactiveId); + const value = varInfo.value; + if (value) { + const dependency = this.getDependency(value); + + if (isUseHook(value)) { + if (dependency) { + this.addUsedReactives(dependency.depIdBitmap); + } + this.addStmt({ + type: 'derived', + ids: varIds, + lVal: id.node, + reactiveId: reactiveId, + value, + source: DerivedSource.HOOK, + dependency, + hookArgDependencies: getHookProps(value, this.getDependency), + }); + return; + } + + if (dependency) { + this.addUsedReactives(dependency.depIdBitmap); + this.addStmt({ + type: 'derived', + ids: varIds, + lVal: id.node, + reactiveId: reactiveId, + value, + source: DerivedSource.STATE, + dependency, + }); + + return; + } + } + + this.addStmt({ + type: 'state', + name: id.node, + value, + reactiveId, + node: varInfo.node, + }); + } + + private parseIdInLVal(id: NodePath, reactiveId?: number) { + let varIds: string[] = []; + if (id.isIdentifier()) { + const name = id.node.name; + this.addDeclaredReactive(name, reactiveId); + varIds.push(name); + } else if (id.isObjectPattern() || id.isArrayPattern()) { + const destructuredNames = searchNestedProps(id); + destructuredNames.forEach(name => { + this.addDeclaredReactive(name, reactiveId); + }); + varIds = destructuredNames; + } + return varIds; + } + + addContext(id: NodePath, context: t.Identifier) { + assertIdOrDeconstruct(id, 'Invalid Variable type when using context: ' + id.type); + + this.addStmt({ + type: 'useContext', + lVal: id.node, + context, + }); + } + + private addUsedReactives(usedIdBits: number) { + this.#current.scope.usedIdBits |= usedIdBits; + } + + addSubComponent(subComp: ComponentNode) { + this.#current.scope.usedIdBits |= subComp.scope.usedIdBits; + this.addStmt({ + type: 'subComp', + component: subComp, + name: subComp.name, + }); + } + + addLifecycle(lifeCycle: LifeCycle, callback: NodePath | NodePath) { + this.addStmt({ + type: 'lifecycle', + lifeCycle, + callback, + }); + } + + addWatch( + callback: NodePath | NodePath, + dependency: Dependency | null + ) { + if (dependency) { + this.addUsedReactives(dependency.depIdBitmap); + } + this.addStmt({ + type: 'watch', + callback, + dependency, + }); + } + + setViewChild(viewNode: AllowedJSXNode) { + assertComponentNode(this.#current); + + const viewUnits = parseJSX(viewNode, { + babelApi: getBabelApi(), + htmlTags: this.#htmlTags, + parseTemplate: false, + }); + + const [viewParticle, useIdBits] = parseReactivity(viewUnits, { + babelApi: getBabelApi(), + reactiveMap: this.getGlobalReactiveMap(), + reactivityFuncNames, + }); + + this.addStmt({ + type: 'viewReturn', + value: viewParticle, + }); + this.addUsedReactives(useIdBits); + } + + setEmptyView() { + this.addStmt({ + type: 'viewReturn', + value: null, + }); + } + + setReturnValue(expression: t.Expression) { + assertHookNode(this.#current); + const dependency = this.getDependency(expression); + + if (dependency) { + this.addUsedReactives(dependency.depIdBitmap); + } + this.addStmt({ + type: 'hookReturn', + value: expression, + ...dependency, + }); + } + + checkSubComponent(subCompName: string) { + return !!this.#current.body.find(sub => sub.type === 'subComp' && sub.name === subCompName); + } + + startSubComponent(name: string, fnNode: NodePath | NodePath) { + assertComponentNode(this.#current); + this.#current = createIRNode(name, COMPONENT, fnNode, this.#current); + } + + endSubComponent() { + const subComp = this.#current as ComponentNode; // we start from a component node + this.#current = this.#current.parent!; + this.addSubComponent(subComp); + } + + build() { + const idToWaveBitMap = new Map(); + pruneUnusedState(this.#current, idToWaveBitMap); + // wave map is a map from reactive id to wave bit + const waveBitsMap = new Map(); + + function buildWaveMap(block: IRBlock) { + for (let i = block.body.length - 1; i >= 0; i--) { + const stmt = block.body[i]; + if (stmt.type === 'state' || stmt.type === 'derived' || isPropStmt(stmt)) { + const waveBits = getWaveBits(idToWaveBitMap, stmt, waveBitsMap); + if (waveBits) { + waveBitsMap.set(stmt.reactiveId, waveBits); + if (stmt.type === 'derived') { + trackSource(waveBitsMap, stmt, waveBits); + } + } + } + } + } + + // post order traverse to build wave map because + // e.g. a = b, b = c, a need to know c's wave bit, + // so we need to traverse bottom up + function traverse(node: ComponentNode | HookNode) { + node.body.forEach(stmt => { + if (stmt.type === 'subComp') { + traverse(stmt.component); + } + }); + buildWaveMap(node); + } + + traverse(this.#current); + return [this.#current, new BitManager(waveBitsMap, idToWaveBitMap)] as const; + } +} + +export class BitManager { + constructor( + private readonly waveBitsMap: Map, + private readonly idToWaveBitMap: Map + ) {} + + getWaveBits = (block: IRBlock, name: string) => { + let current: IRBlock | undefined = block; + while (current) { + const id = current.scope.reactiveMap.get(name); + if (id) { + return this.waveBitsMap.get(id) ?? 0; + } + current = current.parent; + } + return 0; + }; + + getWaveBitsById = (id: number) => { + return this.waveBitsMap.get(id) ?? 0; + }; + + getReactBits = (idBitmap: number) => { + return bitmapToIndices(idBitmap).reduce((acc, depId) => { + const waveBit = this.idToWaveBitMap.get(depId); + if (waveBit) { + return acc | waveBit; + } + throw new Error(`wave bit not found for id ${depId}`); + }, 0); + }; +} + +/** + * Iterate identifier in nested destructuring, collect the identifier that can be used + * e.g. function ({prop1, prop2: [p20X, {p211, p212: p212X}]} + * we should collect prop1, p20X, p211, p212X + * @param idPath + */ +export function searchNestedProps(idPath: NodePath) { + const nestedProps: string[] | null = []; + + if (idPath.isObjectPattern() || idPath.isArrayPattern()) { + idPath.traverse({ + Identifier(path) { + // judge if the identifier is a prop + // 1. is the key of the object property and doesn't have alias + // 2. is the item of the array pattern and doesn't have alias + // 3. is alias of the object property + const parentPath = path.parentPath; + if (parentPath.isObjectProperty() && path.parentKey === 'value') { + // collect alias of the object property + nestedProps.push(path.node.name); + } else if ( + parentPath.isArrayPattern() || + parentPath.isObjectPattern() || + parentPath.isRestElement() || + (parentPath.isAssignmentPattern() && path.key === 'left') + ) { + // collect the key of the object property or the item of the array pattern + nestedProps.push(path.node.name); + } + }, + }); + } + + return nestedProps; +} + +function getDestructure(path: NodePath) { + if (path.isAssignmentPattern()) { + const left = path.get('left'); + if (left.isObjectPattern() || left.isArrayPattern()) { + return left; + } + } else if (path.isObjectPattern() || path.isArrayPattern()) { + return path; + } + return null; +} + +function getHookProps(value: t.CallExpression, getDependency: (node: t.Expression | t.Statement) => Dependency | null) { + const params = value.arguments; + + return params.map(param => { + if (t.isSpreadElement(param)) { + return getDependency(param.argument); + } + if (t.isArgumentPlaceholder(param)) { + return null; + } + return getDependency(param); + }); +} \ No newline at end of file diff --git a/packages/inula-vscode-plugin/src/analyze/index.ts b/packages/inula-vscode-plugin/src/analyze/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff68b69011b637ebeaac9d5a7d46efd7889c24ba --- /dev/null +++ b/packages/inula-vscode-plugin/src/analyze/index.ts @@ -0,0 +1,87 @@ +import { type NodePath } from '@babel/core'; +import { AnalyzeContext, Analyzer, CompOrHook, FunctionalExpression } from './types'; +import { variablesAnalyze } from './Analyzers/variablesAnalyze'; +import { functionalMacroAnalyze } from './Analyzers/functionalMacroAnalyze'; +import { getFnBodyPath } from '../utils'; +import { viewAnalyze } from './Analyzers/viewAnalyze'; +import { COMPONENT, HOOK } from '../constants'; +import { types as t } from '@openinula/babel-api'; +import { hookReturnAnalyze } from './Analyzers/hookAnalyze'; +import { IRBuilder } from './IRBuilder'; +import { compPropsAnalyze, hookPropsAnalyze } from './Analyzers/propsAnalyze'; +import { mergeVisitor } from '../utils'; + +const compBuiltinAnalyzers = [variablesAnalyze, functionalMacroAnalyze, viewAnalyze, compPropsAnalyze] as Analyzer[]; +const hookBuiltinAnalyzers = [ + variablesAnalyze, + functionalMacroAnalyze, + hookReturnAnalyze, + hookPropsAnalyze, +] as Analyzer[]; + +// walk through the body a function (maybe a component or a hook) +export function analyzeUnitOfWork(name: string, fnNode: NodePath, context: AnalyzeContext) { + const { builder, analyzers } = context; + const visitor = mergeVisitor(...analyzers); + + // --- analyze the function props --- + const params = fnNode.get('params'); + if (params.length > 0) { + visitor.Props?.(params, context); + } + + // --- analyze the function body --- + const bodyStatements = getFnBodyPath(fnNode).get('body'); + for (let i = 0; i < bodyStatements.length; i++) { + const path = bodyStatements[i]; + + const type = path.node.type; + + const visit = visitor[type]; + if (visit) { + // TODO: More type safe way to handle this + visit(path as unknown as any, context); + } else { + builder.addRawStmt(path.node); + } + } +} + +/** + * The process of analyzing the component + * 1. identify the component + * 2. identify the jsx slice in the component + * 2. identify the component's props, including children, alias, and default value + * 3. analyze the early return of the component, build into the branch + * + * @param type + * @param fnName + * @param path + * @param options + */ +export function analyze( + type: CompOrHook, + fnName: string, + path: NodePath, + options: { customAnalyzers?: Analyzer[]; htmlTags: string[] } +) { + const analyzers = options?.customAnalyzers ? options.customAnalyzers : getBuiltinAnalyzers(type); + + const builder = new IRBuilder(fnName, type, path, options.htmlTags); + + analyzeUnitOfWork(fnName, path, { builder, analyzers }); + + return builder.build(); +} + +function getBuiltinAnalyzers(type: CompOrHook) { + if (type === COMPONENT) { + return compBuiltinAnalyzers; + } + if (type === HOOK) { + return hookBuiltinAnalyzers; + } + throw new Error('Unsupported type to analyze'); +} + +export { CompOrHook }; \ No newline at end of file diff --git a/packages/inula-vscode-plugin/src/analyze/nodeFactory.ts b/packages/inula-vscode-plugin/src/analyze/nodeFactory.ts new file mode 100644 index 0000000000000000000000000000000000000000..031036c7b2d841c49c5d04148c57f3f90086815b --- /dev/null +++ b/packages/inula-vscode-plugin/src/analyze/nodeFactory.ts @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { NodePath } from '@babel/core'; +import { ComponentNode, FunctionalExpression, CompOrHook, HookNode, IRScope } from './types'; +import { COMPONENT } from '../constants'; + +export function createIRNode( + name: string, + type: T, + fnNode: NodePath, + parent?: ComponentNode +): HookNode | ComponentNode { + const parentScope = parent?.scope; + const comp: HookNode | ComponentNode = { + type: type === COMPONENT ? 'comp' : 'hook', + params: fnNode.node.params, + name, + body: [ + { + type: 'init', + }, + ], + parent, + fnNode, + scope: createScope(parentScope), + }; + + return comp; +} + +function createScope(parentScope: IRScope | undefined) { + return { + level: parentScope ? parentScope.level + 1 : 0, + reactiveMap: new Map(), + usedIdBits: 0, + }; +} \ No newline at end of file diff --git a/packages/inula-vscode-plugin/src/analyze/parseDestructuring.ts b/packages/inula-vscode-plugin/src/analyze/parseDestructuring.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd45c51ee0426177eb5b554e8bd8ec2919769cf8 --- /dev/null +++ b/packages/inula-vscode-plugin/src/analyze/parseDestructuring.ts @@ -0,0 +1,58 @@ +import type { NodePath } from '@babel/core'; +import { types as t } from '@openinula/babel-api'; +import { CompilerError } from '@openinula/error-handler'; + +export type DestructuringPayload = + | { + type: 'rest'; + name: string; + } + | { + type: 'single'; + name: string; + value: NodePath; + node: t.ObjectProperty; + } + | { + type: 'props'; + name: string; + node: t.Identifier; + }; + +type DestructuringDispatch = (payload: DestructuringPayload) => void; +export function parseDestructuring( + path: NodePath, + dispatch: DestructuringDispatch +) { + if (path.isObjectPattern()) { + path.get('properties').forEach(prop => { + if (prop.isRestElement()) { + // --- rest element --- + const arg = prop.get('argument'); + if (!Array.isArray(arg) && arg.isIdentifier()) { + dispatch({ type: 'rest', name: arg.node.name }); + return; + } + throw new CompilerError('Unsupported rest element type in object destructuring', prop.node.loc); + } else if (prop.isObjectProperty()) { + // --- normal property --- + const key = prop.node.key; + if (t.isIdentifier(key) || t.isStringLiteral(key)) { + const name = t.isIdentifier(key) ? key.name : key.value; + dispatch({ type: 'single', name, value: prop.get('value'), node: prop.node }); + return; + } + + throw new CompilerError(`Unsupported key type in object destructuring: ${key.type}`, prop.node.loc); + } + }); + } else if (path.isIdentifier()) { + // --- props identifier --- + dispatch({ type: 'props', name: path.node.name, node: path.node }); + } else { + throw new CompilerError( + `Component: The first parameter of the function component must be an object pattern or identifier`, + path.node.loc + ); + } +} diff --git a/packages/inula-vscode-plugin/src/analyze/pruneUnusedState.ts b/packages/inula-vscode-plugin/src/analyze/pruneUnusedState.ts new file mode 100644 index 0000000000000000000000000000000000000000..853c8516dc05be219d4ae0d5e6f2256b25c7915c --- /dev/null +++ b/packages/inula-vscode-plugin/src/analyze/pruneUnusedState.ts @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { ComponentNode, HookNode } from './types'; + +/** + * To prune the bitmap of unused properties + * etc.: + * ```js + * let a = 1; // 0b001 + * let b = 2; // 0b010 if b is not *used*, so it should be pruned + * let c = 3; // 0b100 -> 0b010(cause bit of b is pruned) + * let d = a + c // The depMask of d should be 0b11, pruned from 0b101 + * ``` + */ +export function pruneUnusedState( + comp: ComponentNode<'comp'> | ComponentNode<'subComp'> | HookNode, + idMap: Map, + bitIndex = -1 +) { + const reactiveMap = comp.scope.reactiveMap; + const usedIdBits = comp.scope.usedIdBits; + + let preId: number; + Array.from(reactiveMap).forEach(([, idBit]) => { + if (usedIdBits & idBit) { + if (preId !== idBit) { + // the reactive shared same id with previous one, we should not change the index + bitIndex++; + } + idMap.set(idBit, 1 << bitIndex); + } + preId = idBit; + }); + + for (const stmt of comp.body) { + if (stmt.type === 'subComp') { + bitIndex = pruneUnusedState(stmt.component, idMap, bitIndex); + } + } + + return bitIndex; +} \ No newline at end of file diff --git a/packages/inula-vscode-plugin/src/analyze/types.ts b/packages/inula-vscode-plugin/src/analyze/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..89bb149efffbaa462474cd9b6d4559fb078bf19b --- /dev/null +++ b/packages/inula-vscode-plugin/src/analyze/types.ts @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { type NodePath, types as t } from '@babel/core'; +import { COMPONENT, DID_MOUNT, DID_UNMOUNT, HOOK, PropType, WILL_MOUNT, WILL_UNMOUNT } from '../constants'; +import { ViewParticle } from '@openinula/reactivity-parser'; +import { IRBuilder } from './IRBuilder'; +import { Dependency } from '@openinula/reactivity-parser'; + +export type CompOrHook = typeof COMPONENT | typeof HOOK; +export type LifeCycle = typeof WILL_MOUNT | typeof DID_MOUNT | typeof WILL_UNMOUNT | typeof DID_UNMOUNT; + +export type FunctionalExpression = t.FunctionExpression | t.ArrowFunctionExpression; +export interface BaseVariable { + id: NodePath; + value: V; + kind: t.VariableDeclaration['kind']; + node: t.VariableDeclarator; +} + +export const PARAM_PROPS = 'props'; +export const CTX_PROPS = 'ctx'; +export type PropsSource = typeof PARAM_PROPS | typeof CTX_PROPS; + +export interface SinglePropStmt { + name: string | number; + value: t.LVal; + reactiveId: number; + type: PropType.SINGLE; + isDestructured: boolean; + defaultValue?: t.Expression | null; + source: PropsSource; + ctxName?: string; +} +export interface RestPropStmt { + name: string; + type: PropType.REST; + reactiveId: number; + source: PropsSource; + ctxName?: string; +} + +export interface WholePropStmt { + name: string; + value: t.Identifier; + reactiveId: number; + type: PropType.WHOLE; + source: PropsSource; + ctxName?: string; +} + +export type RawStmt = { + type: 'raw'; + value: t.Statement; +}; + +export type WatchStmt = { + type: 'watch'; + callback: NodePath | NodePath; + dependency: Dependency | null; +}; + +export type LifecycleStmt = { + type: 'lifecycle'; + callback: NodePath | NodePath; + lifeCycle: LifeCycle; +}; + +export type InitStmt = { + type: 'init'; +}; + +export type SubCompStmt = { + type: 'subComp'; + name: string; + component: ComponentNode; +}; + +export type StateStmt = { + type: 'state'; + name: t.Identifier | t.ArrayPattern | t.ObjectPattern; + value: t.Expression | null; + reactiveId: number; + node: t.VariableDeclarator; +}; + +export enum DerivedSource { + HOOK = 'hook', + STATE = 'state', +} + +export type DerivedStmt = { + type: 'derived'; + ids: string[]; + lVal: t.Identifier | t.ArrayPattern | t.ObjectPattern; + reactiveId: number; +} & ( + | { + source: DerivedSource.HOOK; + value: t.CallExpression; + dependency: Dependency | null; + hookArgDependencies: Array; + } + | { + value: t.Expression; + dependency: Dependency; + source: DerivedSource.STATE; + } +); + +export type ViewReturnStmt = { + type: 'viewReturn'; + value: ViewParticle | null; +}; + +export type UseContextStmt = { + type: 'useContext'; + lVal: t.Identifier | t.ArrayPattern | t.ObjectPattern; + context: t.Identifier; +}; + +export type HookReturnStmt = { + type: 'hookReturn'; + value: t.Expression; +} & Partial; + +export type UseHookStmt = { + type: 'useHook'; + name: string; + hook: HookNode; +}; + +export type IRStmt = + | RawStmt + | WatchStmt + | LifecycleStmt + | InitStmt + | SubCompStmt + | DerivedStmt + | StateStmt + | SinglePropStmt + | RestPropStmt + | WholePropStmt + | ViewReturnStmt + | UseContextStmt + | HookReturnStmt; + +export interface IRScope { + /** + * The map to find the reactive id bit by name + */ + reactiveMap: Map; + /** + * The bits of the used reactive ids + * e.g. we have 3 reactives, a,b,c, and bc is used, then usedIdBits = 0b110 + */ + usedIdBits: number; + level: number; +} +export interface IRBlock { + name: string; + params: t.FunctionExpression['params']; + body: IRStmt[]; + parent?: IRBlock; + scope: IRScope; + /** + * The function body of the fn component code + */ + fnNode: NodePath; +} + +export interface ComponentNode extends IRBlock { + type: Type; + parent?: ComponentNode; +} +export interface HookNode extends IRBlock { + type: 'hook'; + parent?: ComponentNode | HookNode; +} + +export interface AnalyzeContext { + builder: IRBuilder; + analyzers: Analyzer[]; +} + +export type Visitor = { + [Type in t.Node['type']]?: (path: NodePath>, state: S) => void; +} & { + Props?: (path: NodePath[], state: S) => void; +}; +export type Analyzer = () => Visitor; + +export interface FnComponentDeclaration extends t.FunctionDeclaration { + id: t.Identifier; +} diff --git a/packages/inula-vscode-plugin/src/analyze/utils.ts b/packages/inula-vscode-plugin/src/analyze/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..f37b10d5194fe3f098607acf22ac719cd214a4f3 --- /dev/null +++ b/packages/inula-vscode-plugin/src/analyze/utils.ts @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2024 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { types as t } from '@openinula/babel-api'; +import { NodePath } from '@babel/core'; +import { ComponentNode, FnComponentDeclaration, HookNode } from './types'; +import { builtinHooks, HOOK_USING_PREFIX } from '../constants'; +import { CompilerError } from '@openinula/error-handler'; + +export function isValidPath(path: NodePath): path is NodePath> { + return !!path.node; +} + +// The component name must be UpperCamelCase +export function isValidComponent(node: t.FunctionDeclaration): node is FnComponentDeclaration { + // the first letter of the component name must be uppercase + return node.id ? isValidComponentName(node.id.name) : false; +} + +export function isValidComponentName(name: string) { + // the first letter of the component name must be uppercase + return /^[A-Z]/.test(name); +} + +export function hasJSX(path: NodePath) { + if (path.isJSXElement()) { + return true; + } + + // check if there is JSXElement in the children + let seen = false; + path.traverse({ + JSXElement() { + seen = true; + }, + }); + return seen; +} + +export function extractFnBody(node: t.FunctionExpression | t.ArrowFunctionExpression): t.Statement { + if (node.async) { + // async should return iife + return t.expressionStatement(t.callExpression(node, [])); + } + + // For non-async functions, just return the body + return t.isStatement(node.body) ? node.body : t.expressionStatement(node.body); +} + +export function isStaticValue(node: t.VariableDeclarator['init']) { + return ( + (t.isLiteral(node) && !t.isTemplateLiteral(node)) || + t.isArrowFunctionExpression(node) || + t.isFunctionExpression(node) + ); +} + +export function assertComponentNode(node: any): asserts node is ComponentNode { + if (node.type !== 'comp' && node.type !== 'subComp') { + throw new CompilerError('Analyze: Should be component node', node.loc); + } +} + +export function assertHookNode(node: any): asserts node is HookNode { + if (node.type !== 'hook') { + throw new CompilerError('Analyze: Should be hook node', node.loc); + } +} + +/** + * Check if the node is a useXXX call expression + * @param node + * @returns + */ +export function isUseHook(node: t.Node): node is t.CallExpression { + if (t.isCallExpression(node)) { + const callee = node.callee; + return t.isIdentifier(callee) && callee.name.startsWith(HOOK_USING_PREFIX) && !builtinHooks.includes(callee.name); + } + return false; +} diff --git a/packages/inula-vscode-plugin/src/babel-plugin-syntax-jsx.d.ts b/packages/inula-vscode-plugin/src/babel-plugin-syntax-jsx.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..50a2ac65d0b4c8febb20eb1a3e6142e5a7f71d12 --- /dev/null +++ b/packages/inula-vscode-plugin/src/babel-plugin-syntax-jsx.d.ts @@ -0,0 +1 @@ +declare module '@babel/plugin-syntax-jsx'; \ No newline at end of file diff --git a/packages/inula-vscode-plugin/src/constants.ts b/packages/inula-vscode-plugin/src/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..3941b1ba548ec2d1434cdafa944d9962cd20522e --- /dev/null +++ b/packages/inula-vscode-plugin/src/constants.ts @@ -0,0 +1,554 @@ +import { IRStmt, RestPropStmt, SinglePropStmt, WholePropStmt } from './analyze/types'; + +export const COMPONENT = 'Component'; +export const HOOK = 'Hook'; +export const WILL_MOUNT = 'willMount'; +export const DID_MOUNT = 'didMount'; +export const WILL_UNMOUNT = 'willUnmount'; +export const DID_UNMOUNT = 'didUnmount'; + +export const WATCH = 'watch'; + +export const CURRENT_COMPONENT = '$$self'; +export enum PropType { + REST = 'restProp', + SINGLE = 'singleProp', + WHOLE = 'wholeProp', +} + +export function isPropStmt(stmt: IRStmt): stmt is SinglePropStmt | WholePropStmt | RestPropStmt { + return (Object.values(PropType) as string[]).includes(stmt.type); +} + +export const reactivityFuncNames = [ + // ---- Array + 'push', + 'pop', + 'shift', + 'unshift', + 'splice', + 'sort', + 'reverse', + // ---- Set + 'add', + 'delete', + 'clear', + // ---- Map + 'set', + 'delete', + 'clear', +]; + +// --- api for users +export const USE_CONTEXT = 'useContext'; +// --- api for compiler +const API_NAMES = [ + 'createElement', + 'setStyle', + 'setDataset', + 'setEvent', + 'delegateEvent', + 'setHTMLProp', + 'setHTMLAttr', + 'setHTMLProps', + 'setHTMLAttrs', + 'createTextNode', + 'updateText', + 'insertNode', + 'appendNode', + 'render', + 'notCached', + 'useHook', + 'createHook', + 'untrack', + 'runOnce', + 'createNode', + 'updateNode', + 'updateChildren', + 'setProp', + 'setRef', + 'initContextChildren', + 'initCompNode', + 'emitUpdate', + 'compBuilder', + 'hookBuilder', + 'createCompNode', + 'createHTMLNode', + 'createFragmentNode', + 'createForNode', + 'createConditionalNode', + 'templateGetElement', + 'createTemplateNode', + 'createTextNode', + 'createExpNode', + 'createContextNode', + 'createSuspenseNode', + 'createPortal', + 'setText', + 'withDefault', + 'useContext', + 'createChildren', + 'setHTMLAttrs', +] as const; + +export const originalImportMap = Object.fromEntries(API_NAMES.map(name => [name, `$$${name}`])) as ImportMapType; + +// 生成 ImportMap 类型 +export type ImportMapType = { + readonly [K in (typeof API_NAMES)[number]]: string; +}; + +const accessedKeys = new Set(); + +export const importMap: ImportMapType = new Proxy(originalImportMap, { + get(target, prop: string, receiver) { + accessedKeys.add(prop); + return Reflect.get(target, prop, receiver); + }, +}); + +// 函数用于获取被访问过的键 +export function getAccessedKeys() { + return Array.from(accessedKeys).reduce>((map, key) => { + map[key] = (originalImportMap as Record)[key]; + return map; + }, {}); +} + +// 函数用于重置访问记录 +export function resetAccessedKeys() { + accessedKeys.clear(); +} + +export const alterAttributeMap = { + class: 'className', + for: 'htmlFor', +}; + +/** + * @brief HTML internal attribute map, can be accessed as js property + */ +export const defaultAttributeMap = { + // ---- Other property as attribute + textContent: ['*'], + innerHTML: ['*'], + // ---- Source: https://developer.mozilla.org/zh-CN/docs/Web/HTML/Attributes + accept: ['form', 'input'], + // ---- Original: accept-charset + acceptCharset: ['form'], + accesskey: ['*'], + action: ['form'], + align: ['caption', 'col', 'colgroup', 'hr', 'iframe', 'img', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr'], + allow: ['iframe'], + alt: ['area', 'img', 'input'], + async: ['script'], + autocapitalize: ['*'], + autocomplete: ['form', 'input', 'select', 'textarea'], + autofocus: ['button', 'input', 'select', 'textarea'], + autoplay: ['audio', 'video'], + background: ['body', 'table', 'td', 'th'], + // ---- Original: base + bgColor: ['body', 'col', 'colgroup', 'marquee', 'table', 'tbody', 'tfoot', 'td', 'th', 'tr'], + border: ['img', 'object', 'table'], + buffered: ['audio', 'video'], + capture: ['input'], + charset: ['meta'], + checked: ['input'], + cite: ['blockquote', 'del', 'ins', 'q'], + className: ['*'], + color: ['font', 'hr'], + cols: ['textarea'], + // ---- Original: colspan + colSpan: ['td', 'th'], + content: ['meta'], + // ---- Original: contenteditable + contentEditable: ['*'], + contextmenu: ['*'], + controls: ['audio', 'video'], + coords: ['area'], + crossOrigin: ['audio', 'img', 'link', 'script', 'video'], + csp: ['iframe'], + data: ['object'], + // ---- Original: datetime + dateTime: ['del', 'ins', 'time'], + decoding: ['img'], + default: ['track'], + defer: ['script'], + dir: ['*'], + dirname: ['input', 'textarea'], + disabled: ['button', 'fieldset', 'input', 'optgroup', 'option', 'select', 'textarea'], + download: ['a', 'area'], + draggable: ['*'], + enctype: ['form'], + // ---- Original: enterkeyhint + enterKeyHint: ['textarea', 'contenteditable'], + htmlFor: ['label', 'output'], + form: ['button', 'fieldset', 'input', 'label', 'meter', 'object', 'output', 'progress', 'select', 'textarea'], + // ---- Original: formaction + formAction: ['input', 'button'], + // ---- Original: formenctype + formEnctype: ['button', 'input'], + // ---- Original: formmethod + formMethod: ['button', 'input'], + // ---- Original: formnovalidate + formNoValidate: ['button', 'input'], + // ---- Original: formtarget + formTarget: ['button', 'input'], + headers: ['td', 'th'], + height: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'], + hidden: ['*'], + high: ['meter'], + href: ['a', 'area', 'base', 'link'], + hreflang: ['a', 'link'], + // ---- Original: http-equiv + httpEquiv: ['meta'], + id: ['*'], + integrity: ['link', 'script'], + // ---- Original: intrinsicsize + intrinsicSize: ['img'], + // ---- Original: inputmode + inputMode: ['textarea', 'contenteditable'], + ismap: ['img'], + // ---- Original: itemprop + itemProp: ['*'], + kind: ['track'], + label: ['optgroup', 'option', 'track'], + lang: ['*'], + language: ['script'], + loading: ['img', 'iframe'], + list: ['input'], + loop: ['audio', 'marquee', 'video'], + low: ['meter'], + manifest: ['html'], + max: ['input', 'meter', 'progress'], + // ---- Original: maxlength + maxLength: ['input', 'textarea'], + // ---- Original: minlength + minLength: ['input', 'textarea'], + media: ['a', 'area', 'link', 'source', 'style'], + method: ['form'], + min: ['input', 'meter'], + multiple: ['input', 'select'], + muted: ['audio', 'video'], + name: [ + 'button', + 'form', + 'fieldset', + 'iframe', + 'input', + 'object', + 'output', + 'select', + 'textarea', + 'map', + 'meta', + 'param', + ], + // ---- Original: novalidate + noValidate: ['form'], + open: ['details', 'dialog'], + optimum: ['meter'], + pattern: ['input'], + ping: ['a', 'area'], + placeholder: ['input', 'textarea'], + // ---- Original: playsinline + playsInline: ['video'], + poster: ['video'], + preload: ['audio', 'video'], + readonly: ['input', 'textarea'], + // ---- Original: referrerpolicy + referrerPolicy: ['a', 'area', 'iframe', 'img', 'link', 'script'], + rel: ['a', 'area', 'link'], + required: ['input', 'select', 'textarea'], + reversed: ['ol'], + role: ['*'], + rows: ['textarea'], + // ---- Original: rowspan + rowSpan: ['td', 'th'], + sandbox: ['iframe'], + scope: ['th'], + scoped: ['style'], + selected: ['option'], + shape: ['a', 'area'], + size: ['input', 'select'], + sizes: ['link', 'img', 'source'], + slot: ['*'], + span: ['col', 'colgroup'], + spellcheck: ['*'], + src: ['audio', 'embed', 'iframe', 'img', 'input', 'script', 'source', 'track', 'video'], + srcdoc: ['iframe'], + srclang: ['track'], + srcset: ['img', 'source'], + start: ['ol'], + step: ['input'], + style: ['*'], + summary: ['table'], + // ---- Original: tabindex + tabIndex: ['*'], + target: ['a', 'area', 'base', 'form'], + title: ['*'], + translate: ['*'], + type: ['button', 'input', 'embed', 'object', 'ol', 'script', 'source', 'style', 'menu', 'link'], + usemap: ['img', 'input', 'object'], + value: ['button', 'data', 'input', 'li', 'meter', 'option', 'progress', 'param', 'text' /** extra for TextNode */], + width: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'], + wrap: ['textarea'], + // --- ARIA attributes + // Source: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes + ariaAutocomplete: ['*'], + ariaChecked: ['*'], + ariaDisabled: ['*'], + ariaErrorMessage: ['*'], + ariaExpanded: ['*'], + ariaHasPopup: ['*'], + ariaHidden: ['*'], + ariaInvalid: ['*'], + ariaLabel: ['*'], + ariaLevel: ['*'], + ariaModal: ['*'], + ariaMultiline: ['*'], + ariaMultiSelectable: ['*'], + ariaOrientation: ['*'], + ariaPlaceholder: ['*'], + ariaPressed: ['*'], + ariaReadonly: ['*'], + ariaRequired: ['*'], + ariaSelected: ['*'], + ariaSort: ['*'], + ariaValuemax: ['*'], + ariaValuemin: ['*'], + ariaValueNow: ['*'], + ariaValueText: ['*'], + ariaBusy: ['*'], + ariaLive: ['*'], + ariaRelevant: ['*'], + ariaAtomic: ['*'], + ariaDropEffect: ['*'], + ariaGrabbed: ['*'], + ariaActiveDescendant: ['*'], + ariaColCount: ['*'], + ariaColIndex: ['*'], + ariaColSpan: ['*'], + ariaControls: ['*'], + ariaDescribedBy: ['*'], + ariaDescription: ['*'], + ariaDetails: ['*'], + ariaFlowTo: ['*'], + ariaLabelledBy: ['*'], + ariaOwns: ['*'], + ariaPosInset: ['*'], + ariaRowCount: ['*'], + ariaRowIndex: ['*'], + ariaRowSpan: ['*'], + ariaSetSize: ['*'], +}; + +export const defaultHTMLTags = [ + 'a', + 'abbr', + 'address', + 'area', + 'article', + 'aside', + 'audio', + 'b', + 'base', + 'bdi', + 'bdo', + 'blockquote', + 'body', + 'br', + 'button', + 'canvas', + 'caption', + 'cite', + 'code', + 'col', + 'colgroup', + 'data', + 'datalist', + 'dd', + 'del', + 'details', + 'dfn', + 'dialog', + 'div', + 'dl', + 'dt', + 'em', + 'embed', + 'fieldset', + 'figcaption', + 'figure', + 'footer', + 'form', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hgroup', + 'hr', + 'html', + 'i', + 'iframe', + 'img', + 'input', + 'ins', + 'kbd', + 'label', + 'legend', + 'li', + 'link', + 'main', + 'map', + 'mark', + 'menu', + 'meta', + 'meter', + 'nav', + 'noscript', + 'object', + 'ol', + 'optgroup', + 'option', + 'output', + 'p', + 'picture', + 'pre', + 'progress', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'script', + 'section', + 'select', + 'slot', + 'small', + 'source', + 'span', + 'strong', + 'style', + 'sub', + 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'template', + 'textarea', + 'tfoot', + 'th', + 'thead', + 'time', + 'title', + 'tr', + 'track', + 'u', + 'ul', + 'var', + 'video', + 'wbr', + 'acronym', + 'applet', + 'basefont', + 'bgsound', + 'big', + 'blink', + 'center', + 'dir', + 'font', + 'frame', + 'frameset', + 'isindex', + 'keygen', + 'listing', + 'marquee', + 'menuitem', + 'multicol', + 'nextid', + 'nobr', + 'noembed', + 'noframes', + 'param', + 'plaintext', + 'rb', + 'rtc', + 'spacer', + 'strike', + 'tt', + 'xmp', + 'animate', + 'animateMotion', + 'animateTransform', + 'circle', + 'clipPath', + 'defs', + 'desc', + 'ellipse', + 'feBlend', + 'feColorMatrix', + 'feComponentTransfer', + 'feComposite', + 'feConvolveMatrix', + 'feDiffuseLighting', + 'feDisplacementMap', + 'feDistantLight', + 'feDropShadow', + 'feFlood', + 'feFuncA', + 'feFuncB', + 'feFuncG', + 'feFuncR', + 'feGaussianBlur', + 'feImage', + 'feMerge', + 'feMergeNode', + 'feMorphology', + 'feOffset', + 'fePointLight', + 'feSpecularLighting', + 'feSpotLight', + 'feTile', + 'feTurbulence', + 'filter', + 'foreignObject', + 'g', + 'image', + 'line', + 'linearGradient', + 'marker', + 'mask', + 'metadata', + 'mpath', + 'path', + 'pattern', + 'polygon', + 'polyline', + 'radialGradient', + 'rect', + 'set', + 'stop', + 'svg', + 'switch', + 'symbol', + 'text', + 'textPath', + 'tspan', + 'use', + 'view', +]; +export const PROP_SUFFIX = '_$p$_'; +export const HOOK_SUFFIX = '_$h$_'; +// The suffix means to bind to context specific key +export const SPECIFIC_CTX_SUFFIX = '_$c$_'; +// The suffix means to bind to whole context +export const WHOLE_CTX_SUFFIX = '_$ctx$_'; +export const builtinHooks = ['useContext']; +export const HOOK_USING_PREFIX = 'use'; diff --git a/packages/inula-vscode-plugin/src/extension.ts b/packages/inula-vscode-plugin/src/extension.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf4e6fd6f70a2d74b3fbb4ecb4252eace112bdc8 --- /dev/null +++ b/packages/inula-vscode-plugin/src/extension.ts @@ -0,0 +1,164 @@ +// 入口文件,负责插件的激活和命令注册 +import * as vscode from 'vscode'; +import OpenInulaHoverProvider from './hover/HoverProviderCore'; +import * as babel from '@babel/core'; +import inulaPlugin from './plugin'; +import presetTypescript from '@babel/preset-typescript'; +import presetReact from '@babel/preset-react'; + +// 插件的激活函数,当插件被激活时调用 +export function activate(context: vscode.ExtensionContext) { + vscode.window.showInformationMessage('Inula Analyzer is now active!'); + let disposable = vscode.commands.registerCommand('openinula.analyzeFile', async () => { + try { + // 获取当前活动的文本编辑器 + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showWarningMessage('没有活动的编辑器窗口。'); + return; + } + + const document = editor.document; + const filePath = document.fileName; + // 优先使用 editor.document.getText() 获取代码,这样可以处理未保存的修改 + const code = document.getText(); + + // 使用 await 等待 babel.transformAsync 的异步结果 + const result = await babel.transformAsync(code, { + // 使用当前正在编辑的文件路径,而不是插件本身的路径 + filename: filePath, + plugins: [ + [inulaPlugin, { /* 插件选项 */ }], + ], + presets: [ + // Babel 的 preset 加载顺序是反向的,所以 react 在前 + [presetReact, { runtime: 'automatic' }], + [presetTypescript] + ], + babelrc: false, + configFile: false, + ast: true, + }); + + if (result && result.metadata) { + const analysis = result.metadata as any; + + if (analysis.components && Object.keys(analysis.components).length > 0) { + console.log('Inula 分析结果:', analysis.components); + vscode.window.showInformationMessage(`成功分析了 ${Object.keys(analysis.components).length} 个 Inula 组件。`); + + const keysToRemove = new Set([ + 'loc', 'extra', 'leadingComments', 'trailingComments', 'innerComments', + 'parent', 'hub', 'scope', 'file', 'path', 'node', + 'parentPath', 'fnNode', '_traverseFlags', 'skipKeys', 'contexts', 'opts', + '_exploded', '_verified', 'shorthand', 'computed', 'decorators', + 'interpreter', 'sourceType', 'errors', 'id', 'generator', 'async' + ]); + + function simplifyObject(obj: any): any { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(simplifyObject); + } + + const newObj: { [key: string]: any } = {}; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key) && !keysToRemove.has(key)) { + const value = obj[key]; + if (typeof value === 'string' && (value === '[Omitted for brevity]' || value === '[Circular]')) { + // Skip properties with these placeholder values + } else { + newObj[key] = simplifyObject(value); + } + } + } + return newObj; + } + + if (result && result.ast) { + const simplifiedAst = simplifyObject(result.ast); + const astString = JSON.stringify(simplifiedAst, null, 2); + const astDocument = await vscode.workspace.openTextDocument({ + content: astString, + language: 'json', + }); + await vscode.window.showTextDocument(astDocument, vscode.ViewColumn.Beside); + vscode.window.showInformationMessage('AST 已生成并显示。'); + } + + // 显示原始(未简化)的分析结果 + const getCircularReplacerForOriginal = () => { + const seen = new WeakSet(); + return (key: string, value: any) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + } + return value; + }; + }; + const originalAnalysisString = JSON.stringify(analysis.components, getCircularReplacerForOriginal(), 2); + const originalDocument = await vscode.workspace.openTextDocument({ + content: originalAnalysisString, + language: 'json', + }); + await vscode.window.showTextDocument(originalDocument, vscode.ViewColumn.Beside); + + + // 显示简化后的分析结果 + const simplifiedAnalysis = simplifyObject(analysis.components); + + const analysisString = JSON.stringify(simplifiedAnalysis, null, 2); + + const newDocument = await vscode.workspace.openTextDocument({ + content: analysisString, + language: 'json', + }); + await vscode.window.showTextDocument(newDocument, vscode.ViewColumn.Beside); + } else { + vscode.window.showInformationMessage('在当前文件中没有找到 Inula 组件。'); + } + } else { + vscode.window.showInformationMessage('分析未产生结果。'); + } + } catch (error) { + console.error('命令执行异常:', error); + vscode.window.showErrorMessage(`分析 Inula 代码时出错: ${error instanceof Error ? error.message : String(error)}`); + } + }); + + // 创建一个 OpenInulaHoverProvider 实例 + const hoverProvider = new OpenInulaHoverProvider(); + // 注册悬停提供器,为指定的语言提供悬停信息 + const hoverdisposable = vscode.languages.registerHoverProvider( + ['javascript', 'javascriptreact', 'typescript', 'typescriptreact'], + hoverProvider + ); + + // 将命令和悬停提供器添加到上下文的订阅中 + context.subscriptions.push(hoverdisposable); + context.subscriptions.push(disposable); + context.subscriptions.push( + vscode.commands.registerCommand('simpleJump.jump', (arg: { uri: string; position: { line: number; character: number } }) => { + const uri = vscode.Uri.parse(arg.uri); + const position = new vscode.Position(arg.position.line, arg.position.character); + + vscode.window.showTextDocument(uri).then(editor => { + editor.selection = new vscode.Selection(position, position); + editor.revealRange(editor.selection, vscode.TextEditorRevealType.InCenter); + + OpenInulaHoverProvider.setVirtualPosition(position); + setTimeout(() => { + vscode.commands.executeCommand('editor.action.showDefinitionPreviewHover'); + }, 150); + }); + }) + ); +} + +export function deactivate() {} \ No newline at end of file diff --git a/packages/inula-vscode-plugin/src/generator/compGenerator.ts b/packages/inula-vscode-plugin/src/generator/compGenerator.ts new file mode 100644 index 0000000000000000000000000000000000000000..218c8fc5d81f41fd977670b9313fdaaf2e7645ae --- /dev/null +++ b/packages/inula-vscode-plugin/src/generator/compGenerator.ts @@ -0,0 +1,23 @@ +import { generate, generateSelfId, Generator } from './index'; +import { getBabelApi, types as t } from '@openinula/babel-api'; + +export function compGenerator(): Generator { + return { + /** + * const self = compBuilder() + * @param stmt + * @param ctx + * @returns + */ + init(stmt, { selfId, importMap, parentId }) { + const params = parentId ? [parentId] : []; + + return t.variableDeclaration('const', [ + t.variableDeclarator(selfId, t.callExpression(t.identifier(importMap.compBuilder), params)), + ]); + }, + subComp(stmt, ctx) { + return generate(stmt.component, ctx.bitManager, ctx.hoist, ctx.selfId); + }, + }; +} diff --git a/packages/inula-vscode-plugin/src/generator/functionalMacroGenerator.ts b/packages/inula-vscode-plugin/src/generator/functionalMacroGenerator.ts new file mode 100644 index 0000000000000000000000000000000000000000..43ce585ed7227af5d92179a93fee0f0a23d18773 --- /dev/null +++ b/packages/inula-vscode-plugin/src/generator/functionalMacroGenerator.ts @@ -0,0 +1,33 @@ +import { Generator } from './index'; +import { getBabelApi, types as t } from '@openinula/babel-api'; +import { wrapUpdate } from './utils'; + +export function functionalMacroGenerator(): Generator { + return { + /** + * self.watch(() => { + * console.log(count); + * }, () => [count], 0b1); + * @param stmt + * @returns + */ + watch(stmt, { selfId, getWaveBits, getReactBits }) { + const watchFnBody = stmt.callback.node; + wrapUpdate(selfId, watchFnBody, getWaveBits); + return t.expressionStatement( + t.callExpression(t.memberExpression(selfId, t.identifier('watch')), [ + watchFnBody, + stmt.dependency ? t.arrowFunctionExpression([], stmt.dependency.dependenciesNode) : t.nullLiteral(), + t.numericLiteral(getReactBits(stmt.dependency?.depIdBitmap ?? 0)), + ]) + ); + }, + lifecycle(stmt, { selfId, getWaveBits }) { + const fnBody = stmt.callback.node; + wrapUpdate(selfId, fnBody, getWaveBits); + return t.expressionStatement( + t.callExpression(t.memberExpression(selfId, t.identifier(stmt.lifeCycle)), [fnBody]) + ); + }, + }; +} diff --git a/packages/inula-vscode-plugin/src/generator/hookGenerator.ts b/packages/inula-vscode-plugin/src/generator/hookGenerator.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b8e40e328432fa0717cd0f51d2db39ed9c40f72 --- /dev/null +++ b/packages/inula-vscode-plugin/src/generator/hookGenerator.ts @@ -0,0 +1,32 @@ +import { Generator } from './index'; +import { types as t } from '@openinula/babel-api'; +/** + * @example + * return self.init(() => ${value}, () =>[${deps}], ${reactBits}) + */ +export function hookGenerator(): Generator { + return { + /** + * const self = compBuilder() + * @param stmt + * @param ctx + * @returns + */ + init(stmt, { selfId, importMap, parentId }) { + const params = parentId ? [parentId] : []; + + return t.variableDeclaration('const', [ + t.variableDeclarator(selfId, t.callExpression(t.identifier(importMap.hookBuilder), params)), + ]); + }, + hookReturn(stmt, ctx) { + const params: t.Expression[] = [t.arrowFunctionExpression([], stmt.value)]; + if (stmt.dependenciesNode) { + params.push(t.arrowFunctionExpression([], stmt.dependenciesNode)); + params.push(t.numericLiteral(ctx.getReactBits(stmt.depIdBitmap!))); + } + + return t.returnStatement(t.callExpression(t.memberExpression(ctx.selfId, t.identifier('init')), params)); + }, + }; +} diff --git a/packages/inula-vscode-plugin/src/generator/index.ts b/packages/inula-vscode-plugin/src/generator/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5dabfbc5acb5caa131abe02b8caa206f79498455 --- /dev/null +++ b/packages/inula-vscode-plugin/src/generator/index.ts @@ -0,0 +1,100 @@ +import { ComponentNode, CompOrHook, HookNode, IRStmt } from '../analyze/types'; +import { types as t } from '@openinula/babel-api'; +import { COMPONENT, CURRENT_COMPONENT, HOOK, importMap, type ImportMapType } from '../constants'; +import { stateGenerator } from './stateGenerator'; +import { mergeVisitor } from '../utils'; +import { BitManager } from '../analyze/IRBuilder'; +import { wrapUpdate } from './utils'; +import { rawStmtGenerator } from './rawStmtGenerator'; +import { compGenerator } from './compGenerator'; +import { functionalMacroGenerator } from './functionalMacroGenerator'; +import { propGenerator } from './propGenerator'; +import { hookGenerator } from './hookGenerator'; + +export interface GeneratorContext { + selfId: t.Identifier; + current: ComponentNode | HookNode; + bitManager: BitManager; + hoist: (node: t.Statement | t.Statement[]) => void; + wrapUpdate: (node: t.Statement | t.Expression | null) => void; + getReactBits: (depIdBitmap: number) => number; + getWaveBits: (name: string) => number; + getWaveBitsById: (id: number) => number; + importMap: ImportMapType; + parentId?: t.Identifier; + templates: Array<[string, t.Expression]>; +} + +// Generator type of IRStmt +export type Generator = { + [type in IRStmt['type']]?: (stmt: Extract, state: S) => t.Statement | t.Statement[]; +}; + +const compBuiltinGenerators: Array<() => Generator> = [ + stateGenerator, + rawStmtGenerator, + propGenerator, + compGenerator, + rawStmtGenerator, + functionalMacroGenerator, +]; + +const hookBuiltinGenerators: Array<() => Generator> = [ + stateGenerator, + rawStmtGenerator, + propGenerator, + hookGenerator, + rawStmtGenerator, + functionalMacroGenerator, +]; + +function getBuiltinGenerators(type: 'comp' | 'hook') { + if (type === 'comp') { + return compBuiltinGenerators; + } + if (type === 'hook') { + return hookBuiltinGenerators; + } + throw new Error('Unsupported type to analyze'); +} + +export function generate( + root: ComponentNode | HookNode, + bitManager: BitManager, + hoist: (node: t.Statement | t.Statement[]) => void, + parentId?: t.Identifier +): t.FunctionDeclaration { + const ctx: GeneratorContext = { + selfId: generateSelfId(root.scope.level), + current: root, + bitManager, + hoist, + wrapUpdate: (node: t.Statement | t.Expression | null) => { + wrapUpdate(ctx.selfId, node, ctx.getWaveBits); + }, + getReactBits: bitManager.getReactBits, + getWaveBits: bitManager.getWaveBits.bind(null, root), + getWaveBitsById: bitManager.getWaveBitsById, + templates: [], + importMap, + parentId, + }; + + const visitor = mergeVisitor(...getBuiltinGenerators(root.type)); + const fnBody: t.Statement[] = root.body + .map(stmt => { + const generator = visitor[stmt.type]; + if (generator) { + return generator(stmt as any, ctx); + } + console.warn(`No generator for stmt: ${stmt.type}`); + return []; + }) + .flat(); + + return t.functionDeclaration(t.identifier(root.name), root.params, t.blockStatement(fnBody)); +} + +export function generateSelfId(level: number) { + return t.identifier(`${CURRENT_COMPONENT}${level ? level : ''}`); +} diff --git a/packages/inula-vscode-plugin/src/generator/propGenerator.ts b/packages/inula-vscode-plugin/src/generator/propGenerator.ts new file mode 100644 index 0000000000000000000000000000000000000000..59d13dff9403b5663af6ef1943d146226fbd65c2 --- /dev/null +++ b/packages/inula-vscode-plugin/src/generator/propGenerator.ts @@ -0,0 +1,120 @@ +import { Generator } from './index'; +import { types as t } from '@openinula/babel-api'; +import { PropType, importMap } from '../constants'; +import { CTX_PROPS, PARAM_PROPS, PropsSource } from '../analyze/types'; + +const VAULE_INPUT = '$$value'; + +export function propGenerator(): Generator { + return { + /** + * self.addProp('name', value => name = value, 0b0001) + * @param stmt + * @param selfId + * @param getWaveBitsById + * @returns prop statement + */ + [PropType.SINGLE](stmt, { selfId, getWaveBitsById }) { + const valueId = t.identifier(VAULE_INPUT); + const right = stmt.defaultValue + ? t.callExpression(t.identifier(importMap.withDefault), [valueId, stmt.defaultValue]) + : valueId; + + let valueAssign: t.Expression = t.assignmentExpression('=', stmt.value, right); + if (stmt.isDestructured) { + // destructure assignment should be wrapped with parentheses + valueAssign = t.parenthesizedExpression(valueAssign); + } + + return genAddPropStmt( + selfId, + stmt.name, + valueId, + valueAssign, + getWaveBitsById(stmt.reactiveId), + stmt.source, + stmt.ctxName + ); + }, + /** + * self.addProp('$rest$', value => rest = { ...rest, ...value }, 0b0010) + * @param stmt + * @returns prop statement + */ + [PropType.REST](stmt, { selfId, getWaveBitsById }) { + const valueId = t.identifier(VAULE_INPUT); + const valueAssign = t.assignmentExpression( + '=', + t.identifier(stmt.name), + t.objectExpression([t.spreadElement(t.identifier(stmt.name)), t.spreadElement(valueId)]) + ); + return genAddPropStmt( + selfId, + '$rest$', + valueId, + valueAssign, + getWaveBitsById(stmt.reactiveId), + stmt.source, + stmt.ctxName + ); + }, + /** + * self.addProp('$whole$', value => whole = { ...whole, ...value }, 0b0010) + * @param stmt + * @returns prop statement + */ + [PropType.WHOLE](stmt, { selfId, getWaveBitsById }) { + const valueId = t.identifier(VAULE_INPUT); + const valueAssign = t.assignmentExpression( + '=', + t.identifier(stmt.name), + t.objectExpression([t.spreadElement(t.identifier(stmt.name)), t.spreadElement(valueId)]) + ); + return genAddPropStmt( + selfId, + '$whole$', + valueId, + valueAssign, + getWaveBitsById(stmt.reactiveId), + stmt.source, + stmt.ctxName + ); + }, + /** + * @example + * let { + * name, // 0b0001 + * age, // 0b0010 + * contact: {phone, email}, // 0b0100 + * // ...rest // 0b1000 + * } = useContext(UserContext, self); + */ + useContext(stmt, { selfId }) { + return t.variableDeclaration('let', [ + t.variableDeclarator(stmt.lVal, t.callExpression(t.identifier(importMap.useContext), [stmt.context, selfId])), + ]); + }, + }; +} + +function genAddPropStmt( + selfId: t.Identifier, + key: string | number, + valueId: t.Identifier, + valueAssign: t.Expression, + waveBits: number, + source: PropsSource, + ctxName?: string +): t.Statement | t.Statement[] { + const apiName = source === CTX_PROPS ? 'addContext' : 'addProp'; + const args: t.Expression[] = [ + typeof key === 'string' ? t.stringLiteral(key) : t.numericLiteral(key), + t.arrowFunctionExpression([valueId], valueAssign), + t.numericLiteral(waveBits), + ]; + + if (ctxName) { + args.unshift(t.identifier(ctxName)); + } + return t.expressionStatement(t.callExpression(t.memberExpression(selfId, t.identifier(apiName)), args)); +} diff --git a/packages/inula-vscode-plugin/src/generator/rawStmtGenerator.ts b/packages/inula-vscode-plugin/src/generator/rawStmtGenerator.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd2303f012c164c04c47c1792aa6f167c04bfc3a --- /dev/null +++ b/packages/inula-vscode-plugin/src/generator/rawStmtGenerator.ts @@ -0,0 +1,10 @@ +import { Generator } from './index'; + +export function rawStmtGenerator(): Generator { + return { + raw(stmt, ctx) { + ctx.wrapUpdate(stmt.value); + return stmt.value; + }, + }; +} diff --git a/packages/inula-vscode-plugin/src/generator/stateGenerator.ts b/packages/inula-vscode-plugin/src/generator/stateGenerator.ts new file mode 100644 index 0000000000000000000000000000000000000000..2cf691c5a2c985425434470cee801755a906633b --- /dev/null +++ b/packages/inula-vscode-plugin/src/generator/stateGenerator.ts @@ -0,0 +1,117 @@ +import { DerivedSource } from '../analyze/types'; +import { Generator, GeneratorContext } from './index'; +import { types as t } from '@openinula/babel-api'; +import { Dependency } from '@openinula/reactivity-parser'; + +export function stateGenerator(): Generator { + return { + state(stmt) { + return t.variableDeclaration('let', [stmt.node]); + }, + /* + * Derived includes two part: + * - variable declarement + * - upate call expression + * e.g. + * let double: any; + * self.deriveState(() => double = count * 2, () => [count], 0b0010); + * + * self.useHook(Hook(), (val) => self.wave((double = val), waveBits), (hook) => { + * hook.updateProp('count', 100); + * hook.updateProp('count', () => count, [count], reactBits); + * }) + */ + derived(stmt, ctx) { + const { selfId, getReactBits, getWaveBitsById } = ctx; + + const derivedDeclaration = t.variableDeclaration( + 'let', + stmt.ids.map(id => t.variableDeclarator(t.identifier(id))) + ); + + let updateCall: t.Statement; + if (stmt.source === DerivedSource.HOOK) { + const value = t.identifier('$$value'); + updateCall = t.expressionStatement( + t.callExpression(t.memberExpression(selfId, t.identifier('useHook')), [ + stmt.value, + // update function + t.arrowFunctionExpression( + [value], + t.callExpression(t.memberExpression(selfId, t.identifier('wave')), [ + t.parenthesizedExpression(t.assignmentExpression('=', stmt.lVal, value)), + t.numericLiteral(getWaveBitsById(stmt.reactiveId)), + ]) + ), + // updater + getHookUpdater(stmt.value, stmt.hookArgDependencies, ctx), + ]) + ); + } else { + updateCall = t.expressionStatement( + t.callExpression(t.memberExpression(selfId, t.identifier('deriveState')), [ + // update function + t.arrowFunctionExpression( + [], + t.parenthesizedExpression(t.assignmentExpression('=', stmt.lVal, stmt.value)) + ), + // dependencies node + t.arrowFunctionExpression([], stmt.dependency.dependenciesNode), + // wave bits + t.numericLiteral(getReactBits(stmt.dependency.depIdBitmap)), + ]) + ); + } + return [derivedDeclaration, updateCall]; + }, + }; +} + +/** + * @example + * (hook) => { + * hook.updateProp(0, () => child.name, [child?.name], 1); + * } + * + * @param value + * @param argDependencies + * @param ctx + * @returns + */ +function getHookUpdater(value: t.CallExpression, argDependencies: Array, ctx: GeneratorContext) { + const hook = t.identifier('hook'); + + const args = value.arguments; + + const updatePropsStmts: t.Statement[] = []; + args.forEach((arg, idx) => { + const dependency = argDependencies[idx]; + if (dependency) { + let key: t.Expression = t.numericLiteral(idx); + if (t.isRestElement(arg)) { + key = t.stringLiteral('rest'); + } + let value: t.Expression; + if (t.isSpreadElement(arg)) { + value = arg.argument; + } else if (t.isExpression(arg)) { + value = arg; + } else { + throw new Error('Invalid argument for hook function'); + } + + updatePropsStmts.push( + t.expressionStatement( + t.callExpression(t.memberExpression(hook, t.identifier('updateProp')), [ + key, + t.arrowFunctionExpression([], t.isSpreadElement(arg) ? t.identifier('$$value') : arg), + dependency.dependenciesNode, + t.numericLiteral(ctx.getReactBits(dependency.depIdBitmap)), + ]) + ) + ); + } + }); + + return t.arrowFunctionExpression([hook], t.blockStatement(updatePropsStmts)); +} diff --git a/packages/inula-vscode-plugin/src/generator/utils.ts b/packages/inula-vscode-plugin/src/generator/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef4f69b67c214c22e195a97ee11f0acbb96bf91c --- /dev/null +++ b/packages/inula-vscode-plugin/src/generator/utils.ts @@ -0,0 +1,126 @@ +import type { NodePath } from '@babel/core'; +import { types as t, traverse } from '@openinula/babel-api'; +import { importMap, reactivityFuncNames } from '../constants'; + +export function uid(idx: number) { + return t.stringLiteral(`cache${idx}`); +} + +/** + * @View + * if (Inula.notCached(self, ${uid}, depNode)) {${blockStatement}} + */ +export function wrapCheckCache( + selfId: t.Identifier, + cacheNode: t.ArrayExpression, + statements: t.Statement[], + idx: number +) { + return t.ifStatement( + t.callExpression(t.identifier(importMap.notCached), [selfId, uid(idx), cacheNode]), + t.blockStatement(statements) + ); +} + +/** + * @brief Check if it's the left side of an assignment expression, e.g. count = 1 or count++ + * @param path + * @returns assignment expression + */ +export function isAssignmentExpression( + path: NodePath +): NodePath | NodePath | null { + let parentPath = path.parentPath; + while (parentPath && !t.isStatement(parentPath.node)) { + if (parentPath.isAssignmentExpression()) { + if (parentPath.node.left === path.node) return parentPath; + const leftPath = parentPath.get('left') as NodePath; + if (path.isDescendant(leftPath)) return parentPath; + } else if (parentPath.isUpdateExpression()) { + return parentPath; + } + parentPath = parentPath.parentPath; + } + + return null; +} + +/** + * @View + * xxx = yyy => self.wave(xxx = yyy, 1) + */ +export function wrapUpdate( + selfId: t.Identifier, + node: t.Statement | t.Expression | null, + getWaveBits: (name: string) => number +) { + if (!node) return; + const addWave = (node: t.CallExpression['arguments'][number], bit: number) => { + // add a call to wave and comment show the bit + const bitNode = t.numericLiteral(bit); + t.addComment(bitNode, 'trailing', `0b${bit.toString(2)}`, false); + return t.callExpression(t.memberExpression(selfId, t.identifier('wave')), [node, bitNode]); + }; + traverse(nodeWrapFile(node), { + Identifier: (path: NodePath) => { + if (!getWaveBits(path.node.name)) return; + + const assignmentPath = isAssignmentExpression(path); + if (!assignmentPath) return; + + const assignmentNode = assignmentPath.node; + const writingNode = extractWritingPart(assignmentNode); // the variable writing part of the assignment + if (!writingNode) return; + + // ---- Find all the states in the left + let allBits = 0; + traverse(nodeWrapFile(writingNode), { + Identifier: (path: NodePath) => { + allBits |= getWaveBits(path.node.name); + }, + }); + assignmentPath.replaceWith(addWave(assignmentNode, allBits)); + assignmentPath.skip(); + }, + CallExpression: (path: NodePath) => { + // handle collections mutable methods, like arr.push() + if (!t.isMemberExpression(path.node.callee)) return; + const funcNameNode = path.node.callee.property; + if (!t.isIdentifier(funcNameNode)) return; + if (!reactivityFuncNames.includes(funcNameNode.name)) return; + + // Traverse up the member expression chain to find the root object + let callee = (path.get('callee') as NodePath).get('object'); + while (callee.isMemberExpression()) { + callee = callee.get('object'); + } + + if (callee.isIdentifier()) { + const key = callee.node.name; + + const waveBits = getWaveBits(key); + if (!waveBits) return; + path.replaceWith(addWave(path.node, waveBits)); + } + + path.skip(); + }, + }); +} + +function extractWritingPart(assignmentNode: t.AssignmentExpression | t.UpdateExpression) { + // Handle different types of assignments + if (t.isUpdateExpression(assignmentNode)) { + // For update expressions like ++x or --x + return assignmentNode.argument; + } else if (t.isAssignmentExpression(assignmentNode)) { + // For regular assignments, create a new assignment expression + // with an empty string as right side (placeholder) + return t.assignmentExpression('=', assignmentNode.left, t.stringLiteral('')); + } + return null; +} + +export function nodeWrapFile(node: t.Expression | t.Statement): t.File { + return t.file(t.program([t.isStatement(node) ? node : t.expressionStatement(node)])); +} diff --git a/packages/inula-vscode-plugin/src/hover/ASTTraversal.ts b/packages/inula-vscode-plugin/src/hover/ASTTraversal.ts new file mode 100644 index 0000000000000000000000000000000000000000..75f091b1cc236b6dab1c6f28ccecac48d346c7b1 --- /dev/null +++ b/packages/inula-vscode-plugin/src/hover/ASTTraversal.ts @@ -0,0 +1,258 @@ +import * as vscode from 'vscode'; + +export class ASTTraversal { + private readonly _keysToRemove = new Set([ + 'loc', 'extra', 'leadingComments', 'trailingComments', 'innerComments', + 'parent', 'hub', 'scope', 'file', 'path', 'node', + 'parentPath', 'fnNode', '_traverseFlags', 'skipKeys', 'contexts', 'opts', + '_exploded', '_verified', 'shorthand', 'computed', 'decorators', + 'interpreter', 'sourceType', 'errors', 'id', 'generator', 'async' + ]); + //根据偏移量查找最精确匹配的节点及其作用域 + public findNodeAndScopeAtOffset( + rootNode: any, + offset: number, + initialScope: Map = new Map() + ): { node: any; scope: Map } | null { + let bestMatch: { node: any; scope: Map } | null = null; + let smallestRange = Infinity; + + const traverse = (node: any, scope: Map, visited = new Set()): Map => { + if (!node || typeof node !== 'object' || visited.has(node)) { + return scope; + } + visited.add(node); + + // 1. 为当前节点上下文创建作用域,并添加其内部的声明 + let currentScope = new Map(scope); + const declarations = this.getDeclarationsInNode(node); + for (const decl of declarations) { + currentScope.set(decl.name, decl.node); + } + + // 2. 检查当前节点是否是最佳匹配 + if (node.start !== undefined && node.end !== undefined) { + if (offset >= node.start && offset <= node.end) { + const nodeRange = node.end - node.start; + if (nodeRange < smallestRange) { + smallestRange = nodeRange; + bestMatch = { node: node, scope: currentScope }; + } + } + } + + // 3. 递归遍历子节点,并持续更新作用域 + let keysToTraverse: string[]; + + if (node.type === 'comp') { + keysToTraverse = ['fnNode', 'viewReturn'].filter(k => node[k]); + } else { + const prioritizedKeys = ['body', 'value', 'content', 'properties', 'arguments', 'elements', 'declarations', 'params']; + keysToTraverse = [ + ...prioritizedKeys, + ...Object.keys(node).filter(k => !this._keysToRemove.has(k) && !prioritizedKeys.includes(k)) + ]; + } + + let scopeForChildren = currentScope; + for (const key of keysToTraverse) { + if (node[key]) { + const child = node[key]; + if (Array.isArray(child)) { + for (const item of child) { + scopeForChildren = traverse(item, scopeForChildren, new Set(visited)); + } + } else if (typeof child === 'object') { + const isFunction = child.type === 'ArrowFunctionExpression' || child.type === 'FunctionExpression' || child.type === 'FunctionDeclaration'; + if (isFunction) { + traverse(child, scopeForChildren, new Set(visited)); + } else { + scopeForChildren = traverse(child, scopeForChildren, new Set(visited)); + } + } + } + } + + // 4. 返回处理完所有子节点后的最终作用域 + return scopeForChildren; + }; + + traverse(rootNode, initialScope); + return bestMatch; + } + //从给定的 AST 节点中提取所有声明 + public getDeclarationsInNode(node: any): { name: string, node: any }[] { + const declarations: { name: string, node: any }[] = []; + + if (node.type === 'VariableDeclaration') { + for (const decl of node.declarations || []) { + if (!decl.kind) { + decl.kind = node.kind; + } + if (decl.id.type === 'Identifier') { + declarations.push({ name: decl.id.name, node: decl }); + } else if (decl.id.type === 'ArrayPattern') { + for (const element of decl.id.elements) { + if (element?.type === 'Identifier') { + declarations.push({ name: element.name, node: decl }); + } + } + } + } + } + else if (node.params) { + for (const param of node.params) { + if (param.type === 'Identifier') { + declarations.push({ name: param.name, node: param }); + } + } + } + else if ((node.type === 'raw' || node.type === 'init') && node.value) { + return this.getDeclarationsInNode(node.value); + } + else if (node.type === 'comp' && node.fnNode?.node?.params) { + for (const param of node.fnNode.node.params) { + if (param.type === 'Identifier') { + declarations.push({ name: param.name, node: param }); + } + } + } + + return declarations; + } + //查找作为给定子节点的父级的 MemberExpression 节点 + public findMemberExpressionParent(root: any, child: any): any | null { + let foundParent: any = null; + if (!root || !child) {return null;} + + const visited = new Set(); + const traverse = (node: any) => { + if (!node || typeof node !== 'object' || visited.has(node)) { + return; + } + visited.add(node); + + if (foundParent) {return;} + + if (node.type === 'MemberExpression' && (node.property === child || node.object === child)) { + foundParent = node; + return; + } + + for (const key of Object.keys(node)) { + if (this._keysToRemove.has(key)) {continue;} + const value = node[key]; + if (Array.isArray(value)) { + for(const item of value) {traverse(item);} + } else if (value && typeof value === 'object') { + traverse(value); + } + } + }; + + traverse(root); + return foundParent; + } + //在组件中查找从目标变量派生的所有值 + public findDerivedValues(targetName: string, originalDeclaration: any, componentNode: any): string[] { + const derivedValues: string[] = []; + if (!originalDeclaration) { + return []; + } + + const visited = new Set(); + + const traverse = (node: any, scope: Map): Map => { + if (!node || typeof node !== 'object' || visited.has(node)) { + return scope; + } + visited.add(node); + + let currentScope = new Map(scope); + const declarations = this.getDeclarationsInNode(node); + for (const decl of declarations) { + currentScope.set(decl.name, decl.node); + } + + if (node.type === 'VariableDeclarator' && node.init && node.id.type === 'Identifier' && node.id.name !== targetName) { + const usesTarget = this.expressionContainsIdentifier(node.init, targetName, originalDeclaration, currentScope, new Set()); + if (usesTarget) { + derivedValues.push(`\`${node.id.name}\``); + } + } + + let keysToTraverse: string[]; + if (node.type === 'comp') { + keysToTraverse = ['fnNode', 'viewReturn'].filter(k => node[k]); + } else { + const prioritizedKeys = ['body', 'value', 'content', 'properties', 'arguments', 'elements', 'declarations', 'params']; + keysToTraverse = [ + ...prioritizedKeys, + ...Object.keys(node).filter(k => !this._keysToRemove.has(k) && !prioritizedKeys.includes(k)) + ]; + } + + let scopeForChildren = currentScope; + for (const key of keysToTraverse) { + if (node[key]) { + const child = node[key]; + if (Array.isArray(child)) { + for (const item of child) { + scopeForChildren = traverse(item, scopeForChildren); + } + } else if (typeof child === 'object') { + const isFunction = ['ArrowFunctionExpression', 'FunctionExpression', 'FunctionDeclaration'].includes(child.type); + if (isFunction) { + traverse(child, scopeForChildren); + } else { + scopeForChildren = traverse(child, scopeForChildren); + } + } + } + } + return scopeForChildren; + }; + + traverse(componentNode, new Map()); + return [...new Set(derivedValues)]; + } + //检查表达式是否包含对特定标识符的引用 + public expressionContainsIdentifier( + expressionNode: any, + targetName: string, + originalDeclaration: any, + scope: Map, + visited: Set + ): boolean { + if (!expressionNode || typeof expressionNode !== 'object' || visited.has(expressionNode)) { + return false; + } + visited.add(expressionNode); + + if (expressionNode.type === 'Identifier' && expressionNode.name === targetName) { + const localDeclaration = scope.get(targetName); + if (localDeclaration === originalDeclaration) { + return true; + } + } + + for (const key of Object.keys(expressionNode)) { + if (this._keysToRemove.has(key)) {continue;} + + const child = expressionNode[key]; + if (Array.isArray(child)) { + for (const item of child) { + if (this.expressionContainsIdentifier(item, targetName, originalDeclaration, scope, visited)) { + return true; + } + } + } else if (typeof child === 'object') { + if (this.expressionContainsIdentifier(child, targetName, originalDeclaration, scope, visited)) { + return true; + } + } + } + + return false; + } +} \ No newline at end of file diff --git a/packages/inula-vscode-plugin/src/hover/ExpressionEvaluator.ts b/packages/inula-vscode-plugin/src/hover/ExpressionEvaluator.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b91c2a516a558c608019e77c4ec60ba10f5eaf1 --- /dev/null +++ b/packages/inula-vscode-plugin/src/hover/ExpressionEvaluator.ts @@ -0,0 +1,203 @@ +export class ExpressionEvaluator { + //递归地静态评估 AST 节点的值 + public evaluateExpression( + node: any, + component: any, + scope: Map, + visited = new Set(), + dependenciesContext?: { dependencies: Set } + ): any { + if (!node || visited.has(node)) { return null; } + visited.add(node); + if (visited.size > 50) {return null;} + + switch (node.type) { + case 'NumericLiteral': + case 'StringLiteral': + case 'BooleanLiteral': + return node.value; + case 'NullLiteral': + return null; + case 'Identifier': { + if (dependenciesContext) { dependenciesContext.dependencies.add(node.name); } + if (visited.has(node.name)) { return null; } + visited.add(node.name); + const declaration = scope.get(node.name); + if (declaration && declaration.init) { + return this.evaluateExpression(declaration.init, component, scope, visited, dependenciesContext); + } + return null; + } + case 'ObjectExpression': { + const obj: { [key: string]: any } = {}; + for (const prop of node.properties) { + if (prop.type === 'ObjectProperty') { + const key = prop.key.name || prop.key.value; + obj[key] = this.evaluateExpression(prop.value, component, scope, new Set(visited), dependenciesContext); + } + } + return obj; + } + case 'ArrayExpression': { + let result: any[] = []; + for (const elem of node.elements) { + if (elem.type === 'SpreadElement') { + const spreadArr = this.evaluateExpression(elem.argument, component, scope, new Set(visited), dependenciesContext); + if (Array.isArray(spreadArr)) { + result = result.concat(spreadArr); + } + } else { + result.push(this.evaluateExpression(elem, component, scope, new Set(visited), dependenciesContext)); + } + } + return result; + } + case 'TemplateLiteral': { + let result = ''; + for (let i = 0; i < node.quasis.length; i++) { + result += node.quasis[i].value.raw; + if (i < node.expressions.length) { + const exprValue = this.evaluateExpression(node.expressions[i], component, scope, new Set(visited), dependenciesContext); + result += this.valueToString(exprValue); + } + } + return result; + } + case 'BinaryExpression': { + const left = this.evaluateExpression(node.left, component, scope, new Set(visited), dependenciesContext); + const right = this.evaluateExpression(node.right, component, scope, new Set(visited), dependenciesContext); + if (left === null || right === null) {return null;} + switch (node.operator) { + case '+': return left + right; + case '-': return left - right; + case '*': return left * right; + case '/': return left / right; + case '%': return left % right; + case '==': return left == right; + case '===': return left === right; + case '!=': return left != right; + case '!==': return left !== right; + case '<': return left < right; + case '<=': return left <= right; + case '>': return left > right; + case '>=': return left >= right; + default: return null; + } + } + case 'UnaryExpression': { + const argument = this.evaluateExpression(node.argument, component, scope, new Set(visited), dependenciesContext); + if (argument === null) {return null;} + switch (node.operator) { + case '-': return -argument; + case '+': return +argument; + case '!': return !argument; + case '~': return ~argument; + case 'typeof': return typeof argument; + default: return null; + } + } + case 'LogicalExpression': { + const left = this.evaluateExpression(node.left, component, scope, new Set(visited), dependenciesContext); + if (node.operator === '&&') { + return left && this.evaluateExpression(node.right, component, scope, new Set(visited), dependenciesContext); + } + if (node.operator === '||') { + return left || this.evaluateExpression(node.right, component, scope, new Set(visited), dependenciesContext); + } + if (node.operator === '??') { + return left ?? this.evaluateExpression(node.right, component, scope, new Set(visited), dependenciesContext); + } + return null; + } + case 'ConditionalExpression': { + const test = this.evaluateExpression(node.test, component, scope, new Set(visited), dependenciesContext); + if (test) { + return this.evaluateExpression(node.consequent, component, scope, new Set(visited), dependenciesContext); + } else { + return this.evaluateExpression(node.alternate, component, scope, new Set(visited), dependenciesContext); + } + } + case 'MemberExpression': { + const objectValue = this.evaluateExpression(node.object, component, scope, visited, dependenciesContext); + if (objectValue === null || typeof objectValue !== 'object') { return null; } + + let propertyName: string | number | null = null; + if (node.computed) { + propertyName = this.evaluateExpression(node.property, component, scope, visited, dependenciesContext); + } else { + propertyName = node.property.name; + } + + if (propertyName !== null && propertyName !== undefined && objectValue.hasOwnProperty(propertyName)) { + return (objectValue as any)[propertyName]; + } + return null; + } + case 'CallExpression': { + if (node.callee?.name === 'useState' && node.arguments.length > 0) { + return this.evaluateExpression(node.arguments[0], component, scope, visited, dependenciesContext); + } + return null; + } + default: + return null; + } + } + //将值转换为适合在悬停提示中显示的字符串 + public valueToString(value: any, depth = 0): string { + if (depth > 3) {return '...';} + if (value === null) {return 'null';} + if (value === undefined) {return 'undefined';} + if (typeof value === 'string') {return `"${value}"`;} + if (typeof value === 'number' || typeof value === 'boolean') {return String(value);} + if (Array.isArray(value)) { + if (value.length === 0) {return '[]';} + const items = value.slice(0, 10).map(v => this.valueToString(v, depth + 1)); + return `[${items.join(', ')}${value.length > 10 ? ', ...' : ''}]`; + } + if (typeof value === 'object') { + const properties = Object.entries(value); + if (properties.length === 0) {return '{}';} + const props = properties.slice(0, 5).map(([k, v]) => `${k}: ${this.valueToString(v, depth + 1)}`); + return `{ ${props.join(', ')}${properties.length > 5 ? ', ...' : ''} }`; + } + return 'unknown'; + } + //获取 AST 节点的简短描述 + public getValueDescription(node: any): string { + switch (node.type) { + case 'NumericLiteral': + return `\`${node.value}\``; + case 'StringLiteral': + return `\`"${node.value}"\``; + case 'Identifier': + return `变量 \`${node.name}\``; + case 'ArrowFunctionExpression': + return '箭头函数'; + case 'CallExpression': + return `${this.getCalleeName(node.callee)}()`; + case 'BinaryExpression': + const left = this.getOperandName(node.left); + const right = this.getOperandName(node.right); + return `\`${left} ${node.operator} ${right}\``; + default: + return node.type; + } + } + //获取二元表达式中操作数的名称或字面值 + public getOperandName(operand: any): string { + if (operand.type === 'Identifier') {return operand.name;} + if (operand.type === 'NumericLiteral') {return operand.value.toString();} + if (operand.type === 'StringLiteral') {return `"${operand.value}"`;} + return operand.type; + } + //获取函数调用表达式中被调用函数的名称 + public getCalleeName(callee: any): string { + if (!callee) {return '[未知]';} + if (callee.type === 'Identifier') {return callee.name;} + if (callee.type === 'MemberExpression') { + return `${this.getCalleeName(callee.object)}.${callee.property.name}`; + } + return `[${callee.type}]`; + } +} \ No newline at end of file diff --git a/packages/inula-vscode-plugin/src/hover/HoverProviderCore.ts b/packages/inula-vscode-plugin/src/hover/HoverProviderCore.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3f481d05d3b4d7dc9b42706f18923acb4883c88 --- /dev/null +++ b/packages/inula-vscode-plugin/src/hover/HoverProviderCore.ts @@ -0,0 +1,179 @@ +import * as vscode from 'vscode'; +import * as babel from '@babel/core'; +import inulaPlugin from '../plugin'; +import presetTypescript from '@babel/preset-typescript'; +import presetReact from '@babel/preset-react'; +import { ASTTraversal } from './ASTTraversal'; +import { HoverRenderer } from './HoverRenderer'; + +class OpenInulaHoverProvider implements vscode.HoverProvider { + private static virtualPosition: vscode.Position | null = null; + private readonly _cache = new Map(); + private readonly _documentVersions = new Map(); + + // 导入的功能模块实例 + private readonly _astTraversal = new ASTTraversal(); + private readonly _hoverRenderer = new HoverRenderer(); + //设置一个虚拟的悬停位置 + public static setVirtualPosition(position: vscode.Position): void { + OpenInulaHoverProvider.virtualPosition = position; + } + //提供悬停信息 + public async provideHover( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken + ): Promise { + const positionToShow = OpenInulaHoverProvider.virtualPosition || position; + OpenInulaHoverProvider.virtualPosition = null; + try { + const analysis = await this._getComponentMetadata(document); + if (!analysis) { + return null; + } + + const offset = document.offsetAt(positionToShow); + console.log(`悬停位置: 偏移量 ${offset}`); + + const globalScope = new Map(); + if (analysis.components) { + for (const compName in analysis.components) { + if (Object.prototype.hasOwnProperty.call(analysis.components, compName)) { + const componentInfo = analysis.components[compName]; + + try { + const fileHub = componentInfo.fnNode?.parentPath?.parentPath?.parentPath?.parentPath?.hub?.file; + const binding = fileHub?.scope?.bindings?.[compName]; + + if (binding && binding.identifier.loc) { + const loc = binding.identifier.loc; + const filePath = fileHub?.opts?.filename; + const start = binding?.identifier?.start; + + const enhancedComponentInfo = { ...componentInfo, loc, filePath, start }; + globalScope.set(compName, enhancedComponentInfo); + } else { + globalScope.set(compName, componentInfo); + } + } catch (e) { + console.error(`[HoverProvider] Could not retrieve loc/filePath for component ${compName}`, e); + globalScope.set(compName, componentInfo); + } + } + } + } + + // 遍历所有组件查找匹配的节点 + let foundNode: any = null; + let component: any = null; + let scope: Map | null = null; + + if (analysis.components) { + for (const compName in analysis.components) { + if (analysis.components.hasOwnProperty(compName)) { + const currentComponent = analysis.components[compName]; + const result = this._astTraversal.findNodeAndScopeAtOffset(currentComponent, offset, globalScope); + if (result) { + console.log(`在组件 ${compName} 中找到节点:`, result.node.type, result.node); + foundNode = result.node; + component = currentComponent; + scope = result.scope; + break; + } + } + } + } + + if (!foundNode) { + const wordRange = document.getWordRangeAtPosition(positionToShow); + if (wordRange) { + const hoveredWord = document.getText(wordRange); + if (globalScope.has(hoveredWord)) { + console.log("global",globalScope); + + const targetComponent = globalScope.get(hoveredWord); + console.log(`在组件外部找到对组件 ${hoveredWord}`); + foundNode = targetComponent; + foundNode._isReferenceContext = true; + component = { type: 'File', start: 0, end: document.getText().length, name: 'File' }; + scope = globalScope; + } + } + } + + if (foundNode && component && scope) { + const hoverContent = await this._hoverRenderer.renderFiveLayerInfo(foundNode, component, scope, document, analysis); + return new vscode.Hover(hoverContent); + } + + console.log('未找到匹配的节点'); + return null; + } catch (error) { + console.error('[HoverProvider] Error:', error); + return null; + } + } + //获取并缓存组件的元数据 + private async _getComponentMetadata(document: vscode.TextDocument): Promise { + const uri = document.uri.toString(); + const currentVersion = document.version; + + if (this._cache.has(uri) && this._documentVersions.get(uri) === currentVersion) { + return this._cache.get(uri)!; + } + + const code = document.getText(); + try { + const result = await babel.transformAsync(code, { + filename: document.fileName, + plugins: [ + [inulaPlugin, { /* 插件选项 */ }], + ], + presets: [ + [presetReact, { runtime: 'automatic' }], + [presetTypescript] + ], + babelrc: false, + configFile: false, + }); + + if (!result || !result.metadata) { + return null; + } + + const analysis = result.metadata as any; + + this._cache.set(uri, analysis); + this._documentVersions.set(uri, currentVersion); + return analysis; + } catch (error) { + console.error('Babel transform failed in HoverProvider:', error); + return null; + } + } + //简化编译结果 + private _simplifyObject(obj: any): any { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => this._simplifyObject(item)); + } + + const newObj: { [key: string]: any } = {}; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key) && !this._astTraversal['_keysToRemove'].has(key)) { + const value = obj[key]; + if (typeof value === 'string' && (value === '[Omitted for brevity]' || value === '[Circular]')) { + // 跳过这些值 + } else { + newObj[key] = this._simplifyObject(value); + } + } + } + return newObj; + } +} + +export default OpenInulaHoverProvider; \ No newline at end of file diff --git a/packages/inula-vscode-plugin/src/hover/HoverRenderer.ts b/packages/inula-vscode-plugin/src/hover/HoverRenderer.ts new file mode 100644 index 0000000000000000000000000000000000000000..820d7eba9de655cc905fcd74c3601edb8725d100 --- /dev/null +++ b/packages/inula-vscode-plugin/src/hover/HoverRenderer.ts @@ -0,0 +1,478 @@ +import * as vscode from 'vscode'; +import { ExpressionEvaluator } from './ExpressionEvaluator'; +import { ASTTraversal } from './ASTTraversal'; +import { ReferenceFinder } from './ReferenceFinder'; + +export class HoverRenderer { + private readonly _expressionEvaluator: ExpressionEvaluator; + private readonly _astTraversal: ASTTraversal; + private readonly _referenceFinder: ReferenceFinder; + + constructor() { + this._expressionEvaluator = new ExpressionEvaluator(); + this._astTraversal = new ASTTraversal(); + this._referenceFinder = new ReferenceFinder(); + } + //为给定的 AST 节点渲染完整的五层悬停信息。 + public async renderFiveLayerInfo( + node: any, + component: any, + scope: Map, + document: vscode.TextDocument, + analysis: any + ): Promise { + const md = new vscode.MarkdownString(); + md.isTrusted = true; + md.supportThemeIcons = true; + + // 1. 标题层:名称 + this.renderTitleLayer(md, node); + + // 2. 核心属性层:类型 + 用途 + this.renderCorePropertiesLayer(md, node, component, scope, analysis); + + // 3. 高级属性层:值和依赖关系 + this.renderAdvancedPropertiesLayer(md, node, component, scope, document, analysis); + + // 4. 元数据层:位置信息 + this.renderMetadataLayer(md, node); + + // 5. 交互层:实用操作 + await this.renderInteractiveLayer(md, node, scope, document, analysis); + + return md; + } + //显示节点的名称和图标 + private renderTitleLayer(md: vscode.MarkdownString, node: any): void { + const nodeName = this.getDisplayName(node); + md.appendMarkdown(`### ${this.getNodeIcon(node)} ${nodeName}\n\n`); + } + //确定 AST 节点最合适的显示名称 + private getDisplayName(node: any): string { + if (node.name) {return `\`${node.name}\``;} + if (node.id?.name) {return `\`${node.id.name}\``;} + if (node.key?.name) {return `\`${node.key.name}\``;} + if (node.callee?.name) {return `\`${node.callee.name}()\``;} + if (node.type === 'NumericLiteral') {return `值 \`${node.value}\``;} + if (node.type === 'StringLiteral') {return `字符串 \`"${node.value}"\``;} + return this.getNodeTypeDisplay(node.type); + } + //将 AST 节点类型映射为人类可读的字符串 + private getNodeTypeDisplay(type: string): string { + const typeMap: Record = { + 'Identifier': '标识符', + 'VariableDeclaration': '变量声明', + 'VariableDeclarator': '变量定义', + 'NumericLiteral': '数值', + 'StringLiteral': '字符串', + 'ArrowFunctionExpression': '箭头函数', + 'CallExpression': '函数调用', + 'ObjectProperty': '属性', + 'ImportDeclaration': '导入', + 'comp': '组件', + 'init': '初始化', + 'viewReturn': '视图', + 'raw': '代码块' + }; + return typeMap[type] || type; + } + //获取 AST 节点类型对应的 VS Code 图标 + private getNodeIcon(node: any): string { + const iconMap: Record = { + 'Identifier': '$(symbol-variable)', + 'VariableDeclaration': '$(symbol-constant)', + 'NumericLiteral': '$(symbol-number)', + 'StringLiteral': '$(symbol-string)', + 'ArrowFunctionExpression': '$(symbol-method)', + 'CallExpression': '$(symbol-function)', + 'ObjectProperty': '$(symbol-property)', + 'ImportDeclaration': '$(file-symlink-file)', + 'comp': '$(symbol-class)', + 'init': '$(rocket)', + 'viewReturn': '$(paintcan)' + }; + return iconMap[node.type] || '$(symbol-misc)'; + } + //显示类型和用途信息 + private renderCorePropertiesLayer(md: vscode.MarkdownString, node: any, component: any, scope: Map, analysis: any): void { + const coreInfo = this.getCoreInfo(node, component, scope, analysis); + if (coreInfo.length === 0) {return;} + + md.appendMarkdown('**类型信息**\n'); + coreInfo.forEach(info => { + md.appendMarkdown(`- ${info}\n`); + }); + md.appendMarkdown('\n'); + } + //收集给定 AST 节点的核心信息 + private getCoreInfo(node: any, component: any, scope: Map, analysis: any): string[] { + const info: string[] = []; + const nodeType = node.type; + + switch (node.type) { + case 'Identifier': { + const declaration = scope.get(node.name); + if (declaration) { + if (declaration.type === 'comp') { + info.push(`类型: 组件`); + } else { + const realType = this.getVariableType(declaration.init); + info.push(`类型: ${this.getNodeTypeDisplay(realType)}`); + } + } else { + info.push(`类型: 标识符`); + } + const usage = this.getIdentifierUsage(node, component, scope, analysis); + if (usage) { info.push(`用途: ${usage}`); } + break; + } + + case 'comp': + info.push(`类型: 组件`); + info.push(`用途: 组件定义`); + break; + + case 'VariableDeclaration': + info.push(`声明方式: ${node.kind}`); + break; + + case 'VariableDeclarator': + info.push(`变量类型: ${this.getVariableType(node.init)}`); + break; + + case 'NumericLiteral': + info.push(`类型: 数值字面量`); + break; + + case 'StringLiteral': + info.push(`类型: 字符串字面量`); + break; + + case 'CallExpression': + info.push(`调用类型: 函数调用`); + break; + + case 'ArrowFunctionExpression': + info.push(`类型: 箭头函数`); + info.push(`参数: ${node.params.length}个`); + break; + + case 'ObjectProperty': + info.push(`属性类型: ${node.key.name.startsWith('on') ? '事件' : '数据'}`); + break; + } + + return info; + } + //从变量的初始化节点推断其类型 + private getVariableType(init: any) { + if (!init) {return 'any';} + const typeMap: Record = { + 'NumericLiteral': 'number', + 'StringLiteral': 'string', + 'ArrowFunctionExpression': 'function', + 'CallExpression': 'any', + 'BinaryExpression': 'any', + 'ArrayExpression': 'array', + 'ObjectExpression': 'object' + }; + return typeMap[init.type] || 'object'; + } + //确定标识符在组件上下文中的具体用途 + private getIdentifierUsage(node: any, component: any, scope: Map, analysis: any): string { + const name = node.name; + const declaration = scope.get(name); + + if (declaration) { + if (declaration.type === 'comp') { + if (node._isReferenceContext) { + return '组件引用'; + } + const componentIdNode = declaration.fnNode?.node?.id || declaration.node?.id; + if (componentIdNode && node.start === componentIdNode.start) { + return '组件定义'; + } + return '组件引用'; + } + if (declaration.type === 'VariableDeclarator') { + if (declaration.init?.callee?.name === 'useState') { + if (name.startsWith('set')) { + return '状态更新函数'; + } + return '状态变量'; + } + + if (declaration.init) { + const initType = declaration.init.type; + if (initType === 'ArrowFunctionExpression' || initType === 'FunctionExpression') { + return '函数'; + } + if (initType === 'ClassExpression') { + return '类'; + } + } + + if (declaration.kind === 'const') { + return '常量'; + } + return '变量'; + } + if (declaration.type.includes('Parameter') || declaration.type === 'Identifier') { + return '函数参数'; + } + } + return ''; + } + //显示值和依赖关系 + private renderAdvancedPropertiesLayer(md: vscode.MarkdownString, node: any, component: any, scope: Map, document: vscode.TextDocument, analysis: any): void { + const advancedInfo = this.getAdvancedInfo(node, component, scope, document, analysis); + if (advancedInfo.length === 0) {return;} + + md.appendMarkdown('**详细信息**\n'); + advancedInfo.forEach(info => { + md.appendMarkdown(`- ${info}\n`); + }); + md.appendMarkdown('\n'); + } + //收集节点的高级信息 + private getAdvancedInfo(node: any, component: any, scope: Map, document: vscode.TextDocument, analysis: any): string[] { + const info: string[] = []; + let declarationNode: any = null; + let nodeName: string | null = null; + + if (node.type === 'Identifier' || (node.type === 'comp')) { + const declaration = node.type === 'comp' ? node : scope.get(node.name); + if (declaration && declaration.type === 'comp') { + const targetComponent = declaration; + const paramsCount = targetComponent.fnNode?.node?.params?.length || targetComponent.params?.length || 0; + info.push(`参数数量: ${paramsCount}`); + return info; + } + } + + const memberExprParent = this._astTraversal.findMemberExpressionParent(component, node); + if (memberExprParent && memberExprParent.property === node) { + const value = this._expressionEvaluator.evaluateExpression(memberExprParent, component, scope); + if (value !== null && value !== undefined) { + const parentExprString = document.getText(new vscode.Range( + document.positionAt(memberExprParent.start), + document.positionAt(memberExprParent.end) + )); + info.push(`表达式: \`${parentExprString}\``); + info.push(`值: \`${this._expressionEvaluator.valueToString(value)}\``); + return info; + } + } + + if (node.type === 'Identifier') { + declarationNode = scope.get(node.name); + nodeName = node.name; + } else if (node.type === 'VariableDeclarator') { + declarationNode = node; + nodeName = node.id?.name; + } + + if (declarationNode && nodeName) { + if (declarationNode.id?.type === 'ArrayPattern' && declarationNode.init?.callee?.name === 'useState') { + const element = declarationNode.id.elements.find((e: any) => e?.name === nodeName); + const index = element ? declarationNode.id.elements.indexOf(element) : -1; + + if (index === 0) { + info.push(`类型: 状态变量`); + const initialValue = this._expressionEvaluator.evaluateExpression(declarationNode.init, component, scope, new Set()); + if (initialValue !== null) { + info.push(`初始值: \`${initialValue}\``); + } + } else if (index === 1) { + info.push(`类型: 状态更新函数`); + } + info.push(`依赖: ${this._expressionEvaluator.getValueDescription(declarationNode.init)}`); + + } else if (declarationNode.init) { + if (declarationNode.init.type === 'ArrowFunctionExpression' || + declarationNode.init.type === 'FunctionExpression' || + declarationNode.init.type === 'ArrayExpression' || + declarationNode.init.type === 'ObjectExpression') { + const funcNode = declarationNode.init; + if (funcNode.start !== null && funcNode.end !== null) { + const funcRange = new vscode.Range( + document.positionAt(funcNode.start), + document.positionAt(funcNode.end) + ); + const funcDef = document.getText(funcRange); + info.push(`值:\n\`\`\`\n${funcDef}\n\`\`\``); + } else { + info.push(`值: ${this._expressionEvaluator.getValueDescription(funcNode)}`); + } + } else { + const dependency = this._expressionEvaluator.getValueDescription(declarationNode.init); + const dependenciesContext = { dependencies: new Set() }; + const value = this._expressionEvaluator.evaluateExpression(declarationNode.init, component, scope, new Set([nodeName]), dependenciesContext); + + if (value !== null) { + info.push(`值: \`${value}\``); + if (dependency !== `\`${value}\`` && dependency !== value.toString()) { + info.push(`依赖: ${dependency}`); + } + } else { + info.push(`值: ${dependency}`); + } + + if (dependenciesContext.dependencies.size > 0) { + const depDetails: string[] = []; + const visitedForDeps = new Set([nodeName]); + + for (const depName of dependenciesContext.dependencies) { + if (visitedForDeps.has(depName)) {continue;} + + const depDeclaration = scope.get(depName); + let depString = `* \`${depName}\``; + + if (depDeclaration) { + visitedForDeps.add(depName); + const depPosition = document.positionAt(depDeclaration.start); + const commandArg = { + uri: document.uri.toString(), + position: { + line: depPosition.line, + character: depPosition.character + } + }; + const commandUri = vscode.Uri.parse(`command:simpleJump.jump?${encodeURIComponent(JSON.stringify(commandArg))}`); + depString = `* [${depName}](${commandUri})`; + + if (depDeclaration.init) { + const depValue = this._expressionEvaluator.evaluateExpression(depDeclaration.init, component, scope, visitedForDeps); + if (depValue !== null) { + depString += ` = \`${depValue}\``; + } + } + } + depDetails.push(depString); + } + + if (depDetails.length > 0) { + info.push(`依赖项详情:\n${depDetails.join('\n')}`); + } + } + } + } else if (declarationNode.type === 'Identifier') { + info.push(`来源: 函数参数`); + } + const derivedValues = this._astTraversal.findDerivedValues(nodeName, declarationNode, component); + if (derivedValues.length > 0) { + info.push(`派生值: ${derivedValues.join(', ')}`); + } + } + + // 检查响应性 + if (node.type === 'Identifier' && component?.scope?.reactiveMap?.[node.name]) { + info.push('响应式: ✅ 是'); + } + + // 对于函数调用,显示参数信息 + if (node.type === 'CallExpression') { + if (node.arguments.length > 0) { + const args = node.arguments.map((arg: any) => + this._expressionEvaluator.getValueDescription(arg) + ).join(', '); + info.push(`参数: ${args}`); + } + } + + // 对于数值和字符串,显示具体值 + if (node.type === 'NumericLiteral') { + info.push(`数值: ${node.value}`); + } + if (node.type === 'StringLiteral') { + info.push(`内容: "${node.value}"`); + } + + return info; + } + //显示节点的源代码位置 + private renderMetadataLayer(md: vscode.MarkdownString, node: any): void { + if (node.loc?.start) { + md.appendMarkdown(`**位置**: 第 ${node.loc.start.line} 行, 第 ${node.loc.start.column} 列\n\n`); + } + } + //提供可操作的链接 + private async renderInteractiveLayer(md: vscode.MarkdownString, node: any, scope: Map, document: vscode.TextDocument, analysis: any): Promise { + const nodeName = node.name || node.id?.name; + const actions = await this._referenceFinder.getInteractiveActions(node, scope, document, analysis); + if (actions.length > 0) { + md.appendMarkdown('\n---\n'); + md.appendMarkdown(actions.join(' | ')); + } + if (!nodeName) { return; } + + const declarationNode = scope.get(nodeName); + if (!declarationNode || !declarationNode.loc) { return; } + + // 检查当前节点是否是声明节点 + const isDeclaration = (declarationNode === node || declarationNode.id === node); + + if (isDeclaration) { + const references = this._referenceFinder.findAllReferences(document, analysis, declarationNode); + + if (references.length > 0) { + references.sort((a, b) => a.range.start.compareTo(b.range.start)); + const firstRef = references[0]; + const commandArg = { + uri: firstRef.uri.toString(), + position: { + line: firstRef.range.start.line, + character: firstRef.range.start.character + } + }; + const commandUri = vscode.Uri.parse(`command:simpleJump.jump?${encodeURIComponent(JSON.stringify(commandArg))}`); + + if (references.length === 1) { + md.appendMarkdown(`\n\n---\n\n[$(go-to-file) 跳转到引用](${commandUri})`); + } else { + md.appendMarkdown(`\n\n---\n\n[$(go-to-file) 跳转到第一个引用](${commandUri}) (共 ${references.length} 处)`); + } + } + } else { + const targetUri = document.uri; + const defPosition = document.positionAt(declarationNode.start); + if (!Number.isNaN(defPosition.line) && !Number.isNaN(defPosition.character)) { + const commandArg = { + uri: document.uri.toString(), + position: { + line: defPosition.line, + character: defPosition.character + } + }; + const commandUri = vscode.Uri.parse(`command:simpleJump.jump?${encodeURIComponent(JSON.stringify(commandArg))}`); + md.appendMarkdown(`\n\n---\n\n[$(go-to-file) 跳转到定义](${commandUri})`); + } + + const allReferences = this._referenceFinder.findAllReferences(document, analysis, declarationNode); + + if (allReferences.length > 1) { + allReferences.sort((a, b) => a.range.start.compareTo(b.range.start)); + + const currentOffset = node.start; + const currentIndex = allReferences.findIndex(ref => document.offsetAt(ref.range.start) === currentOffset); + + if (currentIndex > -1) { + const nextIndex = (currentIndex + 1) % allReferences.length; + const nextRef = allReferences[nextIndex]; + + const nextRefPosition = nextRef.range.start; + + const nextCommandArg = { + uri: nextRef.uri.toString(), + position: { + line: nextRefPosition.line, + character: nextRefPosition.character + } + }; + const nextCommandUri = vscode.Uri.parse(`command:simpleJump.jump?${encodeURIComponent(JSON.stringify(nextCommandArg))}`); + md.appendMarkdown(` | [跳转到下一引用](${nextCommandUri})`); + } + } + } + } +} \ No newline at end of file diff --git a/packages/inula-vscode-plugin/src/hover/ReferenceFinder.ts b/packages/inula-vscode-plugin/src/hover/ReferenceFinder.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf06b4a0901d97a7146c01afe7301c0dfadcc372 --- /dev/null +++ b/packages/inula-vscode-plugin/src/hover/ReferenceFinder.ts @@ -0,0 +1,155 @@ +import * as vscode from 'vscode'; +import { ASTTraversal } from './ASTTraversal'; + +export class ReferenceFinder { + private readonly _astTraversal: ASTTraversal; + private readonly _keysToRemove = new Set([ + 'loc', 'extra', 'leadingComments', 'trailingComments', 'innerComments', + 'parent', 'hub', 'scope', 'file', 'path', 'node', + 'parentPath', 'fnNode', '_traverseFlags', 'skipKeys', 'contexts', 'opts', + '_exploded', '_verified', 'shorthand', 'computed', 'decorators', + 'interpreter', 'sourceType', 'errors', 'id', 'generator', 'async' + ]); + + constructor() { + this._astTraversal = new ASTTraversal(); + } + //查找给定定义节点的所有引用 + public findAllReferences( + document: vscode.TextDocument, + analysis: any, + definitionNode: any + ): vscode.Location[] { + const references: vscode.Location[] = []; + + // 统一处理变量声明和函数参数,获取其名称和定义位置 + const definitionName = definitionNode.id?.name ?? definitionNode.name; + if (!definitionName) { + return []; + } + const definitionIdentifierStart = definitionNode.id?.start ?? definitionNode.start; + if (definitionIdentifierStart === undefined) { + return []; + } + // 使用声明节点(VariableDeclarator或参数Identifier)的起始位置作为唯一标识 + const definitionStart = definitionNode.start; + + const traverse = (node: any, scope: Map, visited = new Set()): Map => { + if (!node || typeof node !== 'object' || visited.has(node)) { + return scope; + } + visited.add(node); + + // 1. 为当前节点创建新作用域,并添加其内部的声明 + let currentScope = new Map(scope); + const declarations = this._astTraversal.getDeclarationsInNode(node); + for (const decl of declarations) { + currentScope.set(decl.name, decl.node); + } + + // 2. 检查当前节点是否是目标变量的引用 + if (node.type === 'Identifier' && node.name === definitionName) { + const resolvedDecl = currentScope.get(node.name); + + // 通过比较声明节点的起始位置来确认是否引用了同一个变量 + if (resolvedDecl && resolvedDecl.start === definitionStart) { + // 排除变量定义本身 + if (node.start !== definitionIdentifierStart) { + const startPos = document.positionAt(node.start); + const endPos = document.positionAt(node.end); + references.push(new vscode.Location(document.uri, new vscode.Range(startPos, endPos))); + } + } + } + + // 3. 递归遍历子节点,并正确传递作用域 + let keysToTraverse: string[]; + + // 为我们的自定义 'comp' 节点强制遍历顺序 + if (node.type === 'comp') { + keysToTraverse = ['fnNode', 'viewReturn'].filter(k => node[k]); + } else { + const prioritizedKeys = ['body', 'value', 'content', 'properties', 'arguments', 'elements', 'declarations', 'params']; + keysToTraverse = [ + ...prioritizedKeys, + ...Object.keys(node).filter(k => !this._keysToRemove.has(k) && !prioritizedKeys.includes(k)) + ]; + } + + let scopeForChildren = currentScope; + + for (const key of keysToTraverse) { + if (node[key]) { + const child = node[key]; + if (Array.isArray(child)) { + // 数组(如语句块),按顺序遍历并传递作用域 + for (const item of child) { + scopeForChildren = traverse(item, scopeForChildren, new Set(visited)); + } + } else if (typeof child === 'object') { + const isFunction = child.type === 'ArrowFunctionExpression' || child.type === 'FunctionExpression' || child.type === 'FunctionDeclaration'; + if (isFunction) { + // 函数有自己的作用域,遍历其内部,但不让其作用域泄漏给兄弟节点 + traverse(child, scopeForChildren, new Set(visited)); + } else { + // 对于其他对象,继续传递作用域 + scopeForChildren = traverse(child, scopeForChildren, new Set(visited)); + } + } + } + } + + return scopeForChildren; + }; + + // 从每个组件的根节点开始遍历 + for (const compName in analysis) { + if (analysis.hasOwnProperty(compName)) { + traverse(analysis[compName], new Map(), new Set()); + } + } + + return references; + } + //为给定的 AST 节点生成可交互的操作 + public async getInteractiveActions( + node: any, + scope: Map, + document: vscode.TextDocument, + analysis: any + ): Promise { + const actions: string[] = []; + + if (node.type === 'comp') { + const findReferencesCommand = this.getFindReferencesCommand(node, document, analysis); + if (findReferencesCommand) { + const commandUri = vscode.Uri.parse(`command:${findReferencesCommand.command}?${encodeURIComponent(JSON.stringify(findReferencesCommand.arguments))}`); + actions.push(`[${findReferencesCommand.title}](${commandUri})`); + } + } + return actions; + } + //为给定的节点创建一个“查找所有引用”的 VS Code 命令对象 + public getFindReferencesCommand( + node: any, + document: vscode.TextDocument, + analysis: any + ): { title: string; command: string; arguments: any[] } | null { + const nodeName = node.name || node.id?.name; + + if (!nodeName) { + return null; + } + + const references = this.findAllReferences(document, analysis, node); + if (references.length === 0) { + return null; + } + + return { + title: '跳转到引用', + command: 'editor.action.showReferences', + arguments: [document.uri, references] + }; + } +} \ No newline at end of file diff --git a/packages/inula-vscode-plugin/src/plugin.ts b/packages/inula-vscode-plugin/src/plugin.ts new file mode 100644 index 0000000000000000000000000000000000000000..61a967495050a7cf8af5f7db6b251cf820123c5e --- /dev/null +++ b/packages/inula-vscode-plugin/src/plugin.ts @@ -0,0 +1,159 @@ +import type babel from '@babel/core'; +import type { BabelFile } from '@babel/core'; +import { NodePath, type PluginObj, type types as t } from '@babel/core'; +import { type InulaNextOption } from './types'; +import { defaultAttributeMap, defaultHTMLTags, getAccessedKeys } from './constants'; +import { analyze } from './analyze'; +import { addImport, extractFnFromMacro, fileAllowed, getMacroType, toArray } from './utils'; +import { register } from '@openinula/babel-api'; +import { generate } from './generator'; +import { ComponentNode, HookNode } from './analyze/types'; + +const ALREADY_COMPILED: WeakSet | Set = new (WeakSet ?? Set)(); + +interface PluginState { + customState: Record; + filename: string; + file: BabelFile; +} + + +function transformNode( + path: NodePath, + htmlTags: string[], + state: PluginState, + hoist: (node: t.Statement | t.Statement[]) => void +) { + console.log('[transformNode] Start processing a CallExpression at line:', path.node.loc?.start.line); + if (ALREADY_COMPILED.has(path)) { + console.log('[transformNode] Skipped (already compiled)'); + return false; + } + const type = getMacroType(path); + if (type) { + console.log(`[transformNode] Found a "${type}" macro.`); + const componentNode = extractFnFromMacro(path, type); + let name = ''; + // try to get the component name, when parent is a variable declarator + if (path.parentPath.isVariableDeclarator()) { + const lVal = path.parentPath.get('id'); + if (lVal.isIdentifier()) { + name = lVal.node.name; + } else { + console.error(`${type} macro must be assigned to a variable`); + } + } + console.log(`[transformNode] Analyzing component: ${name || 'anonymous'}`); + const [root, bitManager] = analyze(type, name, componentNode, { + htmlTags, + }); + console.log('[transformNode] Analyzed component:', root); + + + const loc = path.node.loc; + if (!loc) { + console.warn('No location info for node:', path.node); + return false; + } + const metadata = state.file.metadata as any; + console.log('[transformNode] Current metadata:', metadata); + if (!metadata.analysisResults) { + metadata.analysisResults = []; + } + + console.log('[transformNode] Generating new component code.'); + const resultNode = generate(root, bitManager, hoist); + + recordComponentInState(state, name, root); + + replaceWithComponent(path, resultNode); + + console.log('[transformNode] Finished processing macro.'); + return true; + } + + console.log('[transformNode] Not an Inula macro, skipping.'); + ALREADY_COMPILED.add(path); + return false; +} + +export default function (api: typeof babel, options: InulaNextOption): PluginObj { + const { + files = '**/*.{js,ts,jsx,tsx}', + excludeFiles = '**/{dist,node_modules,lib}/*', + packageName = '@openinula/next', + htmlTags: customHtmlTags = defaultHtmlTags => defaultHtmlTags, + attributeMap = defaultAttributeMap, + } = options; + + const htmlTags = + typeof customHtmlTags === 'function' + ? customHtmlTags(defaultHTMLTags) + : customHtmlTags.includes('*') + ? [...new Set([...defaultHTMLTags, ...customHtmlTags])].filter(tag => tag !== '*') + : customHtmlTags; + + register(api); + return { + name: 'babel-inula-next-core', + visitor: { + Program: { + exit(program, state) { + if (!fileAllowed(state.filename, toArray(files), toArray(excludeFiles))) { + program.skip(); + return; + } + let transformationHappenedInFile = false; + + const hoistedNodes: t.Statement[] = []; + const hoist = (node: t.Statement | t.Statement[]) => { + if (Array.isArray(node)) { + hoistedNodes.push(...node); + } else { + hoistedNodes.push(node); + } + }; + + program.traverse({ + CallExpression(path) { + const transformed = transformNode(path, htmlTags, state, hoist); + if (transformed) { + path.skip(); + } + + transformationHappenedInFile = transformed || transformationHappenedInFile; + }, + }); + + program.node.body.unshift(...hoistedNodes); + + if (transformationHappenedInFile && !options.skipImport) { + addImport(program.node, getAccessedKeys(), packageName); + } + }, + }, + }, + }; +} + +function replaceWithComponent(path: NodePath, resultNode: t.FunctionDeclaration) { + const variableDeclarationPath = path.parentPath.parentPath!; + const resultNodeId = resultNode.id; + if (resultNodeId) { + // if id exist, use a temp name to avoid error of duplicate declaration + const realFuncName = resultNodeId.name; + resultNodeId.name = path.scope.generateUid('tmp'); + variableDeclarationPath.replaceWith(resultNode); + resultNodeId.name = realFuncName; + } else { + variableDeclarationPath.replaceWith(resultNode); + } +} + +function recordComponentInState(state: PluginState, name: string, componentNode: ComponentNode | HookNode) { + const metadata = state.file.metadata as { components: Record }; + if (!metadata.components) { + metadata.components = {}; + } + metadata.components[name] = componentNode; +} diff --git a/packages/inula-vscode-plugin/src/test/extension.test.ts b/packages/inula-vscode-plugin/src/test/extension.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3ba5822f5a3e5797c50a95e77c2128e05282cd2 --- /dev/null +++ b/packages/inula-vscode-plugin/src/test/extension.test.ts @@ -0,0 +1,27 @@ +import { analyze, CompOrHook } from '../analyze'; +import * as t from '@babel/types'; +import { NodePath } from '@babel/traverse'; + +// 伪造一个简单的 NodePath 对象 +const fakeNodePath = { + node: t.functionExpression( + t.identifier('TestFunction'), + [], + t.blockStatement([]) + ), + // 根据需要模拟更多 NodePath 的方法和属性 +} as unknown as NodePath; + +describe('analyze function', () => { + it('should return expected analysis result', () => { + const type: CompOrHook = "Component"; // 或 "Hook"// 假设 'comp' 是 CompOrHook 类型的有效值 + const functionName = 'TestFunction'; + const options = { htmlTags: ['div', 'span'] }; + + const result = analyze(type, functionName, fakeNodePath, options); + + expect(result).toBeDefined(); + // 根据 analyze 函数的具体返回值,写具体断言 + // 例如 expect(result).toEqual(expectedResult); + }); +}); \ No newline at end of file diff --git a/packages/inula-vscode-plugin/src/types.ts b/packages/inula-vscode-plugin/src/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..07f3e625bb5debf8e6e0dbe1f57a765eeb249153 --- /dev/null +++ b/packages/inula-vscode-plugin/src/types.ts @@ -0,0 +1,61 @@ +import { type types as t } from '@babel/core'; + +export type HTMLTags = string[] | ((defaultHtmlTags: string[]) => string[]); +export interface InulaNextOption { + /** + * Files that will be included + * @default ** /*.{js,jsx,ts,tsx} + */ + files?: string | string[]; + /** + * Files that will be excludes + * @default ** /{dist,node_modules,lib}/*.{js,ts} + */ + excludeFiles?: string | string[]; + /** + * Enable devtools + * @default false + */ + enableDevTools?: boolean; + /** + * Custom HTML tags. + * Accepts 2 types: + * 1. string[], e.g. ["div", "span"] + * if contains "*", then all default tags will be included + * 2. (defaultHtmlTags: string[]) => string[] + * @default defaultHtmlTags => defaultHtmlTags + */ + htmlTags?: HTMLTags; + /** + * Allowed HTML tags from attributes + * e.g. { alt: ["area", "img", "input"] } + */ + attributeMap?: Record; + /** + * The runtime package name that will be imported from + */ + packageName: string; + /** + * Skip importing the runtime package + */ + skipImport: boolean; +} + +export type PropertyContainer = Record< + string, + { + node: t.ClassProperty | t.ClassMethod; + deps: string[]; + isStatic?: boolean; + isContent?: boolean; + isChildren?: boolean | number; + isModel?: boolean; + isWatcher?: boolean; + isPropOrEnv?: 'Prop' | 'Env'; + depsNode?: t.ArrayExpression; + } +>; + +export type IdentifierToDepNode = t.SpreadElement | t.Expression; + +export type SnippetPropSubDepMap = Record>; diff --git a/packages/inula-vscode-plugin/src/types/babel__preset-typescript.d.ts b/packages/inula-vscode-plugin/src/types/babel__preset-typescript.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3ca02aaf384f9269cf92cdf2b184b95314ebc14 --- /dev/null +++ b/packages/inula-vscode-plugin/src/types/babel__preset-typescript.d.ts @@ -0,0 +1,8 @@ +declare module '@babel/preset-typescript' { + const preset: any; + export default preset; +} +declare module '@babel/preset-react' { + const preset: any; + export default preset; +} \ No newline at end of file diff --git a/packages/inula-vscode-plugin/src/utils.ts b/packages/inula-vscode-plugin/src/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..2628f230b6e3061568b22dde5a5aef5e4ff51c1b --- /dev/null +++ b/packages/inula-vscode-plugin/src/utils.ts @@ -0,0 +1,164 @@ +import { type NodePath } from '@babel/core'; +import { types as t } from '@openinula/babel-api'; +import { COMPONENT, HOOK, importMap, USE_CONTEXT } from './constants'; +import { minimatch } from 'minimatch'; +import { CompilerError } from '@openinula/error-handler'; + +export function fileAllowed(fileName: string | undefined, includes: string[], excludes: string[]): boolean { + if (includes.includes('*')) {return true;} + if (!fileName) {return false;} + if (excludes.some(pattern => minimatch(fileName, pattern))) {return false;} + return includes.some(pattern => minimatch(fileName, pattern)); +} + +export function addImport(programNode: t.Program, importMap: Record, packageName: string) { + programNode!.body.unshift( + t.importDeclaration( + Object.entries(importMap).map(([key, value]) => t.importSpecifier(t.identifier(value), t.identifier(key))), + t.stringLiteral(packageName) + ) + ); +} + +export function toArray(val: T | T[]): T[] { + return Array.isArray(val) ? val : [val]; +} + +export function extractFnFromMacro( + path: NodePath, + macroName: string +): NodePath | NodePath { + const args = path.get('arguments'); + + const fnNode = args[0]; + if (fnNode.isFunctionExpression() || fnNode.isArrowFunctionExpression()) { + return fnNode; + } + + throw new CompilerError(`${macroName} macro must have a function argument`, path.node.loc); +} + +export function isFnExp(node: t.Node | null | undefined): node is t.FunctionExpression | t.ArrowFunctionExpression { + return t.isFunctionExpression(node) || t.isArrowFunctionExpression(node); +} + +export function getFnBodyPath(path: NodePath) { + const fnBody = path.get('body'); + if (fnBody.isExpression()) { + // turn expression into block statement for consistency + fnBody.replaceWith(t.blockStatement([t.returnStatement(fnBody.node)])); + } + + return fnBody as unknown as NodePath; +} + +export function getFnBodyNode(node: t.FunctionExpression | t.ArrowFunctionExpression) { + const fnBody = node.body; + if (t.isExpression(fnBody)) { + // turn expression into block statement for consistency + return t.blockStatement([t.returnStatement(fnBody)]); + } + + return fnBody; +} + +export function isCompPath(path: NodePath) { + // find the component, like: Component(() => {}) + const callee = path.get('callee'); + return callee.isIdentifier() && callee.node.name === COMPONENT; +} + +export function isUseContext(path: NodePath) { + // find the use context, like: useContext() + const callee = path.get('callee'); + return callee.isIdentifier() && callee.node.name === USE_CONTEXT; +} + +export function isHookPath(path: NodePath) { + // find the component, like: Component(() => {}) + const callee = path.get('callee'); + return callee.isIdentifier() && callee.node.name === HOOK; +} + +export function getMacroType(path: NodePath) { + if (isCompPath(path)) { + return COMPONENT; + } + if (isHookPath(path)) { + return HOOK; + } + + return null; +} + +export interface ArrowFunctionWithBlock extends t.ArrowFunctionExpression { + body: t.BlockStatement; +} + +export function wrapArrowFunctionWithBlock(path: NodePath): ArrowFunctionWithBlock { + const { node } = path; + if (node.body.type !== 'BlockStatement') { + node.body = t.blockStatement([t.returnStatement(node.body)]); + } + + return node as ArrowFunctionWithBlock; +} + +export function createMacroNode( + fnBody: t.BlockStatement, + macroName: string, + params: t.FunctionExpression['params'] = [] +) { + return t.callExpression(t.identifier(macroName), [t.arrowFunctionExpression(params, fnBody)]); +} + +export function isValidPath(path: NodePath): path is NodePath> { + return !!path.node; +} + +/** + * Wrap the expression with untrack + * e.g. untrack(() => a) + */ +export function wrapUntrack(node: t.Expression) { + return t.callExpression(t.identifier(importMap.untrack), [t.arrowFunctionExpression([], node)]); +} + +/** + * Convert a bitmap to an array of indices where bits are set to 1 + * @param {number} bitmap - The bitmap to convert + * @returns {number[]} Array of indices where bits are set + */ +export function bitmapToIndices(bitmap: number): number[] { + // Handle edge cases + if (bitmap === 0) {return [];} + if (bitmap < 0) {throw new Error('Negative numbers are not supported');} + + const indices = []; + let currentBit = 0; + + // Continue until we've processed all set bits + while (bitmap > 0) { + // Check if current bit is set + if (bitmap & 1) { + indices.push(1 << currentBit); + } + + // Move to next bit + bitmap = bitmap >>> 1; + currentBit++; + } + + return indices; +} +export function mergeVisitor>(...visitors: Array<() => T>): T { + return visitors.reduce((acc, cur) => { + return { ...acc, ...cur() }; + }, {} as T); +} + +export function assertIdOrDeconstruct(id: NodePath, error: string): asserts id is NodePath { + if (!id.isIdentifier() && !id.isObjectPattern() && !id.isArrayPattern()) { + throw new Error(error); + } +} diff --git a/packages/inula-vscode-plugin/tsconfig.json b/packages/inula-vscode-plugin/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..6386779494158ce287cde4eb58cf4db0be4a5bf2 --- /dev/null +++ b/packages/inula-vscode-plugin/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es6", + "outDir": "dist", + "lib": ["es6"], + "sourceMap": true, + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "*": ["src/*"] + }, + "resolveJsonModule": true + }, + "exclude": ["node_modules", ".vscode-test"], + "include": ["src/**/*.ts"] +} \ No newline at end of file diff --git a/packages/inula-vscode-plugin/vsc-extension-quickstart.md b/packages/inula-vscode-plugin/vsc-extension-quickstart.md new file mode 100644 index 0000000000000000000000000000000000000000..f518bb846b126e5164d9fd69e9fe6853f2392557 --- /dev/null +++ b/packages/inula-vscode-plugin/vsc-extension-quickstart.md @@ -0,0 +1,48 @@ +# Welcome to your VS Code Extension + +## What's in the folder + +* This folder contains all of the files necessary for your extension. +* `package.json` - this is the manifest file in which you declare your extension and command. + * The sample plugin registers a command and defines its title and command name. With this information VS Code can show the command in the command palette. It doesn’t yet need to load the plugin. +* `src/extension.ts` - this is the main file where you will provide the implementation of your command. + * The file exports one function, `activate`, which is called the very first time your extension is activated (in this case by executing the command). Inside the `activate` function we call `registerCommand`. + * We pass the function containing the implementation of the command as the second parameter to `registerCommand`. + +## Setup + +* install the recommended extensions (amodio.tsl-problem-matcher, ms-vscode.extension-test-runner, and dbaeumer.vscode-eslint) + + +## Get up and running straight away + +* Press `F5` to open a new window with your extension loaded. +* Run your command from the command palette by pressing (`Ctrl+Shift+P` or `Cmd+Shift+P` on Mac) and typing `Hello World`. +* Set breakpoints in your code inside `src/extension.ts` to debug your extension. +* Find output from your extension in the debug console. + +## Make changes + +* You can relaunch the extension from the debug toolbar after changing code in `src/extension.ts`. +* You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes. + + +## Explore the API + +* You can open the full set of our API when you open the file `node_modules/@types/vscode/index.d.ts`. + +## Run tests + +* Install the [Extension Test Runner](https://marketplace.visualstudio.com/items?itemName=ms-vscode.extension-test-runner) +* Run the "watch" task via the **Tasks: Run Task** command. Make sure this is running, or tests might not be discovered. +* Open the Testing view from the activity bar and click the Run Test" button, or use the hotkey `Ctrl/Cmd + ; A` +* See the output of the test result in the Test Results view. +* Make changes to `src/test/extension.test.ts` or create new test files inside the `test` folder. + * The provided test runner will only consider files matching the name pattern `**.test.ts`. + * You can create folders inside the `test` folder to structure your tests any way you want. + +## Go further + +* Reduce the extension size and improve the startup time by [bundling your extension](https://code.visualstudio.com/api/working-with-extensions/bundling-extension). +* [Publish your extension](https://code.visualstudio.com/api/working-with-extensions/publishing-extension) on the VS Code extension marketplace. +* Automate builds by setting up [Continuous Integration](https://code.visualstudio.com/api/working-with-extensions/continuous-integration). diff --git a/packages/inula-vscode-plugin/webpack.config.js b/packages/inula-vscode-plugin/webpack.config.js new file mode 100644 index 0000000000000000000000000000000000000000..d952b3eabe32a16dcae1b242e643e8b90991e26c --- /dev/null +++ b/packages/inula-vscode-plugin/webpack.config.js @@ -0,0 +1,53 @@ +//@ts-check + +'use strict'; + +const path = require('path'); + +//@ts-check +/** @typedef {import('webpack').Configuration} WebpackConfig **/ + +/** @type WebpackConfig */ +const extensionConfig = { + target: 'node', // VS Code extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/ + mode: 'none', // this leaves the source code as close as possible to the original (when packaging we set this to 'production') + + entry: './src/extension.ts', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'extension.js', + libraryTarget: 'commonjs2' + }, + externals: { + vscode: 'commonjs vscode',// the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ + // modules added here also need to be added in the .vscodeignore file + '@babel/core': 'commonjs @babel/core', + '@babel/preset-react': 'commonjs @babel/preset-react', + '@babel/plugin-syntax-jsx': 'commonjs @babel/plugin-syntax-jsx' + }, + resolve: { + // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader + extensions: ['.ts', '.js'], + alias: { + '@openinula/babel-plugin-inula-next': path.resolve(__dirname, 'src/plugin.ts') + } + }, + module: { + rules: [ + { + test: /\.ts$/, + exclude: /node_modules/, + use: [ + { + loader: 'ts-loader' + } + ] + } + ] + }, + devtool: 'nosources-source-map', + infrastructureLogging: { + level: "log", // enables logging required for problem matchers + }, +}; +module.exports = [ extensionConfig ]; \ No newline at end of file