diff --git a/inspect-extension/ts-features/ts-go-to-definition/README.md b/inspect-extension/ts-features/ts-go-to-definition/README.md new file mode 100644 index 0000000..3c72801 --- /dev/null +++ b/inspect-extension/ts-features/ts-go-to-definition/README.md @@ -0,0 +1,160 @@ +# Go to Definition 测试用例 + +本目录包含用于测试"跳转到定义"功能的测试用例。 + +## 测试场景 + +### 1. 组件属性跳转到 properties/props 定义 + +#### Options API 子组件 (`child-component.mpx`) + +| 父组件属性 | 预期跳转目标 | +| ------------- | ------------------------------- | +| `title` | `properties.title` | +| `count` | `properties.count` | +| `show-header` | `properties.showHeader` | +| `list-data` | `properties.listData` | +| `config` | `properties.config` | +| `name` | `properties.name` (简写形式) | +| `age` | `properties.age` (简写形式) | +| `visible` | `properties.visible` (简写形式) | + +子组件中方法可能定义在不同位置: + +| 父组件事件绑定的方法 | 子组件中定义位置 | 预期跳转目标 | +| --------------------------- | -------------------- | ------------------------------- | +| `onMethodsHandler` | methods 中 | `methods.onMethodsHandler` | +| `onTopLevelHandler` | 顶层(和 data 同级) | `onTopLevelHandler()` | +| `onTopLevelHandlerWithArgs` | 顶层(和 data 同级) | `onTopLevelHandlerWithArgs()` | +| `onTopLevelArrowHandler` | 顶层(箭头函数) | `onTopLevelArrowHandler: () =>` | + +#### Composition API 子组件 - 泛型写法 (`child-component-setup.mpx`) + +| 父组件属性 | 预期跳转目标 | +| ---------- | ------------------------------------ | +| `message` | `defineProps<{ message: string }>` | +| `visible` | `defineProps<{ visible: boolean }>` | +| `items` | `defineProps<{ items: string[] }>` | +| `optional` | `defineProps<{ optional?: string }>` | +| `config` | `defineProps<{ config?: {...} }>` | + +#### Composition API 子组件 - 对象写法 (`child-component-setup-object.mpx`) + +| 父组件属性 | 预期跳转目标 | +| ---------- | ----------------------------------- | +| `msg` | `defineProps({ msg: String })` | +| `count` | `defineProps({ count: Number })` | +| `enabled` | `defineProps({ enabled: Boolean })` | + +#### withDefaults 子组件 (`child-component-with-defaults.mpx`) + +| 父组件属性 | 预期跳转目标 | +| ---------- | ---------------------- | +| `title` | `Props.title` 类型定义 | +| `count` | `Props.count` 类型定义 | +| `theme` | `Props.theme` 类型定义 | + +### 2. 事件方法跳转 + +#### 支持的事件绑定语法 + +| 语法 | 示例 | 说明 | +| ------------------- | ----------------------------- | --------------------- | +| `bindxxx` | `bindtap="handler"` | 绑定事件 | +| `bind:xxx` | `bind:tap="handler"` | 绑定事件(带冒号) | +| `catchxxx` | `catchtap="handler"` | 捕获事件,阻止冒泡 | +| `catch:xxx` | `catch:tap="handler"` | 捕获事件(带冒号) | +| `capture-bind:xxx` | `capture-bind:tap="handler"` | 捕获阶段绑定 | +| `capture-catch:xxx` | `capture-catch:tap="handler"` | 捕获阶段捕获 | +| `mut-bind:xxx` | `mut-bind:tap="handler"` | 互斥事件绑定 (2.8.2+) | +| 动态绑定 | `bindtap="{{ handlerName }}"` | 方法名是变量 | + +#### Options API 父组件 (`parent-component.mpx`) + +点击事件处理方法名,应跳转到对应的定义位置: + +| 模板中的方法 | 定义位置 | 预期跳转目标 | +| ---------------------- | ------------ | --------------------------------------------- | +| `onChildChange` | methods | `methods.onChildChange` | +| `handleViewTap` | methods | `methods.handleViewTap` | +| `handleLongPress` | methods | `methods.handleLongPress` | +| `inputBlur` | methods | `methods.inputBlur` | +| `handleImageLoad` | methods | `methods.handleImageLoad` | +| `setupHandler` | setup 返回值 | `setup() { return { setupHandler } }` | +| `setupHandlerWithArgs` | setup 返回值 | `setup() { return { setupHandlerWithArgs } }` | +| `dataHandler` | data | `data.dataHandler` | +| `computedHandler` | computed | `computed.computedHandler` | + +#### Composition API 父组件 (`parent-component-setup.mpx`) + +点击事件处理方法名,应跳转到 ` + + diff --git a/inspect-extension/ts-features/ts-go-to-definition/child-component-setup.mpx b/inspect-extension/ts-features/ts-go-to-definition/child-component-setup.mpx new file mode 100644 index 0000000..1a9a249 --- /dev/null +++ b/inspect-extension/ts-features/ts-go-to-definition/child-component-setup.mpx @@ -0,0 +1,51 @@ + + + + + diff --git a/inspect-extension/ts-features/ts-go-to-definition/child-component-with-defaults.mpx b/inspect-extension/ts-features/ts-go-to-definition/child-component-with-defaults.mpx new file mode 100644 index 0000000..0ad7f47 --- /dev/null +++ b/inspect-extension/ts-features/ts-go-to-definition/child-component-with-defaults.mpx @@ -0,0 +1,32 @@ + + + + + diff --git a/inspect-extension/ts-features/ts-go-to-definition/child-component.mpx b/inspect-extension/ts-features/ts-go-to-definition/child-component.mpx new file mode 100644 index 0000000..92d256f --- /dev/null +++ b/inspect-extension/ts-features/ts-go-to-definition/child-component.mpx @@ -0,0 +1,44 @@ + + + + + diff --git a/inspect-extension/ts-features/ts-go-to-definition/parent-component-setup.mpx b/inspect-extension/ts-features/ts-go-to-definition/parent-component-setup.mpx new file mode 100644 index 0000000..9b45d60 --- /dev/null +++ b/inspect-extension/ts-features/ts-go-to-definition/parent-component-setup.mpx @@ -0,0 +1,135 @@ + + + + + diff --git a/inspect-extension/ts-features/ts-go-to-definition/parent-component.mpx b/inspect-extension/ts-features/ts-go-to-definition/parent-component.mpx new file mode 100644 index 0000000..fcf1208 --- /dev/null +++ b/inspect-extension/ts-features/ts-go-to-definition/parent-component.mpx @@ -0,0 +1,83 @@ + + + + + diff --git a/inspect-extension/ts-features/ts-go-to-definition/parent-page-setup.mpx b/inspect-extension/ts-features/ts-go-to-definition/parent-page-setup.mpx new file mode 100644 index 0000000..c9fb0e1 --- /dev/null +++ b/inspect-extension/ts-features/ts-go-to-definition/parent-page-setup.mpx @@ -0,0 +1,89 @@ + + + + + diff --git a/inspect-extension/ts-features/ts-go-to-definition/parent-page.mpx b/inspect-extension/ts-features/ts-go-to-definition/parent-page.mpx new file mode 100644 index 0000000..f8e0e2a --- /dev/null +++ b/inspect-extension/ts-features/ts-go-to-definition/parent-page.mpx @@ -0,0 +1,153 @@ + + + + + diff --git a/packages/typescript-plugin/src/common.ts b/packages/typescript-plugin/src/common.ts index aa48ac9..73e4f05 100644 --- a/packages/typescript-plugin/src/common.ts +++ b/packages/typescript-plugin/src/common.ts @@ -8,7 +8,7 @@ import { forEachElementNode, hyphenateTag, } from '@mpxjs/language-core' -import { capitalize } from '@mpxjs/language-shared' +import { camelize, capitalize } from '@mpxjs/language-shared' import { _getComponentNames } from './requests/getComponentNames' import { _getElementNames } from './requests/getElementNames' @@ -243,10 +243,66 @@ function getDefinitionAndBoundSpan( } } + // 从模板位置提取属性名和组件标签名 + const templateContent = root.sfc.template.content + const templateOffset = position - root.sfc.template.startTagEnd + const attrInfo = extractAttributeNameAtPosition( + templateContent, + templateOffset, + ) + const tagInfo = extractTagNameAtPosition(templateContent, templateOffset) + + // 如果在组件属性上(非事件绑定),尝试跳转到子组件的属性定义 + if (attrInfo && tagInfo && !attrInfo.isEvent) { + // 从 usingComponents 中查找子组件路径 + const componentPath = findComponentPath( + root.sfc, + tagInfo.tagName, + fileName, + ) + + if (componentPath) { + const propDefinition = tryFindComponentPropDefinitionByPath( + ts, + language, + asScriptId, + componentPath, + attrInfo.attrName, + ) + if (propDefinition) { + console.log( + '[Mpx Go to Definition] Found prop definition in child component:', + JSON.stringify(propDefinition), + ) + definitions.add(propDefinition) + // 跳过原始定义 + for (const def of result.definitions) { + skippedDefinitions.push(def) + } + } + } + } + for (const definition of result.definitions) { - if ( - mpxOptions.extensions.some(ext => definition.fileName.endsWith(ext)) - ) { + const isMpxFile = mpxOptions.extensions.some(ext => + definition.fileName.endsWith(ext), + ) + + console.log( + '[Mpx Go to Definition] Processing definition:', + JSON.stringify({ + fileName: definition.fileName, + textSpan: definition.textSpan, + isMpxFile, + definitionName: definition.name, + definitionKind: definition.kind, + containerName: definition.containerName, + extensions: mpxOptions.extensions, + }), + ) + + // 跳过已经处理过的组件属性定义 + if (isMpxFile && attrInfo && tagInfo && !attrInfo.isEvent) { continue } @@ -316,6 +372,537 @@ function getDefinitionAndBoundSpan( } } +/** + * 从模板内容中提取当前位置的属性名 + * 支持: title="xxx", show-header="{{ xxx }}", bindtap="handler" + */ +function extractAttributeNameAtPosition( + templateContent: string, + offset: number, +): { attrName: string; isEvent: boolean } | undefined { + // 向前查找属性名的开始位置 + let start = offset + while (start > 0) { + const char = templateContent[start - 1] + // 属性名可以包含字母、数字、连字符、冒号 + if (/[a-zA-Z0-9\-:]/.test(char)) { + start-- + } else { + break + } + } + + // 向后查找属性名的结束位置 + let end = offset + while (end < templateContent.length) { + const char = templateContent[end] + if (/[a-zA-Z0-9\-:]/.test(char)) { + end++ + } else { + break + } + } + + if (start === end) { + return undefined + } + + const attrName = templateContent.slice(start, end) + + // 检查是否是事件绑定 + const isEvent = /^(bind|catch|capture-bind|capture-catch|mut-bind):?/.test( + attrName, + ) + + return { attrName, isEvent } +} + +/** + * 从模板内容中提取当前位置所在的组件标签名 + */ +function extractTagNameAtPosition( + templateContent: string, + offset: number, +): { tagName: string } | undefined { + // 向前查找最近的 < 符号 + let tagStart = offset + while (tagStart > 0) { + if (templateContent[tagStart] === '<') { + break + } + // 如果遇到 > 说明不在标签内 + if (templateContent[tagStart] === '>') { + return undefined + } + tagStart-- + } + + if (tagStart === 0 && templateContent[0] !== '<') { + return undefined + } + + // 跳过 < 和可能的 / + let nameStart = tagStart + 1 + if (templateContent[nameStart] === '/') { + nameStart++ + } + + // 提取标签名 + let nameEnd = nameStart + while (nameEnd < templateContent.length) { + const char = templateContent[nameEnd] + if (/[a-zA-Z0-9\-_]/.test(char)) { + nameEnd++ + } else { + break + } + } + + if (nameStart === nameEnd) { + return undefined + } + + const tagName = templateContent.slice(nameStart, nameEnd) + return { tagName } +} + +/** + * 从 SFC 的 JSON 配置中查找组件路径 + */ +function findComponentPath( + sfc: any, + tagName: string, + currentFileName: string, +): string | undefined { + // 尝试从 script[type="application/json"] 中获取 usingComponents + const jsonBlock = sfc.customBlocks?.find( + (block: any) => + block.type === 'script' && block.attrs?.type === 'application/json', + ) + + if (!jsonBlock?.content) { + // 也尝试从 sfc.json 获取 + if (sfc.json?.content) { + try { + const jsonContent = JSON.parse(sfc.json.content) + const componentPath = jsonContent.usingComponents?.[tagName] + if (componentPath) { + return resolveComponentPath(componentPath, currentFileName) + } + } catch (e) { + console.log('[Mpx Go to Definition] Failed to parse json block:', e) + } + } + return undefined + } + + try { + const jsonContent = JSON.parse(jsonBlock.content) + const componentPath = jsonContent.usingComponents?.[tagName] + if (componentPath) { + return resolveComponentPath(componentPath, currentFileName) + } + } catch (e) { + console.log('[Mpx Go to Definition] Failed to parse json block:', e) + } + + return undefined +} + +/** + * 解析组件相对路径为绝对路径 + */ +function resolveComponentPath( + componentPath: string, + currentFileName: string, +): string { + // 如果是相对路径,解析为绝对路径 + if (componentPath.startsWith('./') || componentPath.startsWith('../')) { + const currentDir = currentFileName.substring( + 0, + currentFileName.lastIndexOf('/'), + ) + // 简单的路径解析 + const parts = componentPath.split('/') + const currentParts = currentDir.split('/') + + for (const part of parts) { + if (part === '.') { + continue + } else if (part === '..') { + currentParts.pop() + } else { + currentParts.push(part) + } + } + + let resolvedPath = currentParts.join('/') + // 添加 .mpx 扩展名(如果没有) + if (!resolvedPath.endsWith('.mpx')) { + resolvedPath += '.mpx' + } + return resolvedPath + } + + // 如果是绝对路径或包路径,直接返回 + return componentPath +} + +/** + * 通过组件路径查找属性定义 + */ +function tryFindComponentPropDefinitionByPath( + ts: typeof import('typescript'), + language: Language, + asScriptId: (fileName: string) => T, + componentPath: string, + attrName: string, +): ts.DefinitionInfo | undefined { + console.log( + '[Mpx Go to Definition] Trying to find prop in component:', + JSON.stringify({ + componentPath, + attrName, + }), + ) + + // 获取目标组件的虚拟代码 + const targetScript = language.scripts.get(asScriptId(componentPath)) + if (!targetScript?.generated?.root) { + console.log( + '[Mpx Go to Definition] No target script found for path:', + componentPath, + ) + return + } + + const targetRoot = targetScript.generated.root + if (!(targetRoot instanceof MpxVirtualCode)) { + console.log('[Mpx Go to Definition] Target root is not MpxVirtualCode') + return + } + + // 获取原始 .mpx 文件的 script 内容 + const sfc = targetRoot.sfc + const scriptBlock = sfc.script || sfc.scriptSetup + if (!scriptBlock) { + console.log( + '[Mpx Go to Definition] No script block found in target component', + ) + return + } + + console.log( + '[Mpx Go to Definition] Target script block info:', + JSON.stringify({ + lang: scriptBlock.lang, + startTagEnd: scriptBlock.startTagEnd, + endTagStart: scriptBlock.endTagStart, + contentLength: scriptBlock.content.length, + }), + ) + + // 在原始脚本内容中查找 properties 定义 + const propDefinitionOffset = findPropertyDefinitionInScript( + ts, + scriptBlock.ast, + attrName, + ) + if (propDefinitionOffset === undefined) { + console.log( + '[Mpx Go to Definition] No prop definition found in target script', + ) + return + } + + // 计算在原始 .mpx 文件中的位置 + const absoluteOffset = scriptBlock.startTagEnd + propDefinitionOffset.start + + console.log( + '[Mpx Go to Definition] Found prop definition:', + JSON.stringify({ + relativeOffset: propDefinitionOffset, + absoluteOffset, + }), + ) + + return { + fileName: componentPath, + textSpan: { + start: absoluteOffset, + length: propDefinitionOffset.length, + }, + kind: ts.ScriptElementKind.memberVariableElement, + name: attrName, + containerName: 'properties', + containerKind: ts.ScriptElementKind.classElement, + } +} + +/** + * 在脚本 AST 中查找 properties 定义 + * 返回相对于脚本内容的偏移量 + */ +function findPropertyDefinitionInScript( + ts: typeof import('typescript'), + scriptAst: ts.SourceFile, + propName: string, +): { start: number; length: number } | undefined { + let result: { start: number; length: number } | undefined + + // 将 kebab-case 转换为 camelCase + const camelizedPropName = camelize(propName) + const possibleNames = [propName, camelizedPropName] + if (propName !== camelizedPropName) { + possibleNames.push(capitalize(camelizedPropName)) + } + + function visit(node: ts.Node) { + if (result) return + + // 查找 createComponent/createPage/Component 调用 + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + ['createComponent', 'createPage', 'Component', 'Page'].includes( + node.expression.text, + ) + ) { + const arg = node.arguments[0] + if (arg && ts.isObjectLiteralExpression(arg)) { + // 查找 properties 属性 + for (const prop of arg.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'properties' + ) { + if (ts.isObjectLiteralExpression(prop.initializer)) { + result = findPropInObjectLiteralAst( + ts, + scriptAst, + prop.initializer, + possibleNames, + ) + } + break + } + } + } + } + + // 查找 defineProps 调用 + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'defineProps' + ) { + // 泛型写法: defineProps<{ propName: string }>() 或 defineProps() + if (node.typeArguments?.length) { + const typeArg = node.typeArguments[0] + if ( + ts.isTypeReferenceNode(typeArg) && + ts.isIdentifier(typeArg.typeName) + ) { + result = findTypeAliasAndPropAst( + ts, + scriptAst, + typeArg.typeName.text, + possibleNames, + ) + } else if (ts.isTypeLiteralNode(typeArg)) { + result = findPropInTypeLiteralAst( + ts, + scriptAst, + typeArg, + possibleNames, + ) + } + } + // 对象写法: defineProps({ propName: String }) + else if (node.arguments.length) { + const arg = node.arguments[0] + if (ts.isObjectLiteralExpression(arg)) { + result = findPropInObjectLiteralAst(ts, scriptAst, arg, possibleNames) + } + } + } + + // 查找 withDefaults(defineProps(), { ... }) + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'withDefaults' + ) { + const definePropsCall = node.arguments[0] + if ( + definePropsCall && + ts.isCallExpression(definePropsCall) && + ts.isIdentifier(definePropsCall.expression) && + definePropsCall.expression.text === 'defineProps' + ) { + if (definePropsCall.typeArguments?.length) { + const typeArg = definePropsCall.typeArguments[0] + // 如果是类型引用,需要找到类型定义 + if ( + ts.isTypeReferenceNode(typeArg) && + ts.isIdentifier(typeArg.typeName) + ) { + const typeName = typeArg.typeName.text + result = findTypeAliasAndPropAst( + ts, + scriptAst, + typeName, + possibleNames, + ) + } else if (ts.isTypeLiteralNode(typeArg)) { + result = findPropInTypeLiteralAst( + ts, + scriptAst, + typeArg, + possibleNames, + ) + } + } + } + } + + if (!result) { + ts.forEachChild(node, visit) + } + } + + visit(scriptAst) + return result +} + +/** + * 在对象字面量 AST 中查找属性定义 + */ +function findPropInObjectLiteralAst( + ts: typeof import('typescript'), + sourceFile: ts.SourceFile, + obj: ts.ObjectLiteralExpression, + possibleNames: string[], +): { start: number; length: number } | undefined { + for (const prop of obj.properties) { + if ( + ts.isPropertyAssignment(prop) || + ts.isShorthandPropertyAssignment(prop) + ) { + const name = prop.name + if (ts.isIdentifier(name) && possibleNames.includes(name.text)) { + return { + start: name.getStart(sourceFile), + length: name.getEnd() - name.getStart(sourceFile), + } + } + if (ts.isStringLiteral(name) && possibleNames.includes(name.text)) { + return { + start: name.getStart(sourceFile), + length: name.getEnd() - name.getStart(sourceFile), + } + } + } + // 支持方法简写: propName() { ... } + if (ts.isMethodDeclaration(prop)) { + const name = prop.name + if (ts.isIdentifier(name) && possibleNames.includes(name.text)) { + return { + start: name.getStart(sourceFile), + length: name.getEnd() - name.getStart(sourceFile), + } + } + } + } + return undefined +} + +/** + * 在类型字面量 AST 中查找属性定义 + */ +function findPropInTypeLiteralAst( + ts: typeof import('typescript'), + sourceFile: ts.SourceFile, + typeLiteral: ts.TypeLiteralNode, + possibleNames: string[], +): { start: number; length: number } | undefined { + for (const member of typeLiteral.members) { + if (ts.isPropertySignature(member)) { + const name = member.name + if (ts.isIdentifier(name) && possibleNames.includes(name.text)) { + return { + start: name.getStart(sourceFile), + length: name.getEnd() - name.getStart(sourceFile), + } + } + if (ts.isStringLiteral(name) && possibleNames.includes(name.text)) { + return { + start: name.getStart(sourceFile), + length: name.getEnd() - name.getStart(sourceFile), + } + } + } + } + return undefined +} + +/** + * 查找类型别名并在其中查找属性 + */ +function findTypeAliasAndPropAst( + ts: typeof import('typescript'), + sourceFile: ts.SourceFile, + typeName: string, + possibleNames: string[], +): { start: number; length: number } | undefined { + let result: { start: number; length: number } | undefined + + function visit(node: ts.Node) { + if (result) return + + if (ts.isTypeAliasDeclaration(node) && node.name.text === typeName) { + if (ts.isTypeLiteralNode(node.type)) { + result = findPropInTypeLiteralAst( + ts, + sourceFile, + node.type, + possibleNames, + ) + } + } + + if (ts.isInterfaceDeclaration(node) && node.name.text === typeName) { + for (const member of node.members) { + if (ts.isPropertySignature(member)) { + const name = member.name + if (ts.isIdentifier(name) && possibleNames.includes(name.text)) { + result = { + start: name.getStart(sourceFile), + length: name.getEnd() - name.getStart(sourceFile), + } + return + } + if (ts.isStringLiteral(name) && possibleNames.includes(name.text)) { + result = { + start: name.getStart(sourceFile), + length: name.getEnd() - name.getStart(sourceFile), + } + return + } + } + } + } + + if (!result) { + ts.forEachChild(node, visit) + } + } + + visit(sourceFile) + return result +} + function getQuickInfoAtPosition( ts: typeof import('typescript'), languageService: ts.LanguageService,