diff --git a/packages/ng-helper-vscode/src/extension.ts b/packages/ng-helper-vscode/src/extension.ts index 18445bff..a9962618 100644 --- a/packages/ng-helper-vscode/src/extension.ts +++ b/packages/ng-helper-vscode/src/extension.ts @@ -10,6 +10,7 @@ import { registerHover } from './features/hover'; import { supportInlineHtml } from './features/inlineHtml'; import { registerLink } from './features/link'; import { registerSemantic } from './features/semantic'; +import { registerSignatureHelp } from './features/signatureHelp'; import { registerStatusBar } from './features/statusBar'; import { NgContext } from './ngContext'; import { StateControl } from './service/stateControl'; @@ -43,6 +44,9 @@ export async function activate(vscodeContext: ExtensionContext) { // completion registerCompletion(ngContext); + // signature help + registerSignatureHelp(ngContext); + // hover registerHover(ngContext); diff --git a/packages/ng-helper-vscode/src/features/completion/type.ts b/packages/ng-helper-vscode/src/features/completion/type.ts index e63d8d0f..e738a56f 100644 --- a/packages/ng-helper-vscode/src/features/completion/type.ts +++ b/packages/ng-helper-vscode/src/features/completion/type.ts @@ -150,13 +150,7 @@ function buildCompletionList(res: NgTypeInfo[]) { : CompletionItemKind.Field, ); - if (x.isFunction) { - // 分两段补全,第一段是函数名,第二段是参数 - let snippet = `${x.name}$1(`; - snippet += x.paramNames!.map((x, i) => `\${${i + 2}:${x}}`).join(', '); - snippet += ')'; - item.insertText = new SnippetString(snippet); - } else if (x.isFilter && x.paramNames?.length) { + if (x.isFilter && x.paramNames?.length) { let snippet = x.name + SPACE; snippet += x.paramNames.map((x, i) => `:\${${i + 1}:${x}}`).join(' '); item.insertText = new SnippetString(snippet); diff --git a/packages/ng-helper-vscode/src/features/inlineHtml/index.ts b/packages/ng-helper-vscode/src/features/inlineHtml/index.ts index 34ad7338..33522131 100644 --- a/packages/ng-helper-vscode/src/features/inlineHtml/index.ts +++ b/packages/ng-helper-vscode/src/features/inlineHtml/index.ts @@ -6,12 +6,14 @@ import { type CompletionList, type Definition, type Hover, + type SignatureHelp, type TextDocument, } from 'vscode'; import type { NgContext } from '../../ngContext'; -import { triggerChars } from '../completion'; +import { triggerChars as completionTriggerChars } from '../completion'; import { htmlSemanticProvider, legend } from '../semantic'; +import { triggerChars as signatureHelpTriggerChars } from '../signatureHelp'; import { getOriginalFileName } from '../utils'; import { resolveVirtualDocText } from './utils'; @@ -34,6 +36,7 @@ export function supportInlineHtml(ngContext: NgContext) { requestForwardHover(ngContext); requestForwardDefinition(ngContext); requestForwardCompletion(ngContext); + requestForwardSignatureHelp(ngContext); } function providerSemantic(ngContext: NgContext) { @@ -154,7 +157,40 @@ function requestForwardCompletion(ngContext: NgContext) { return info; }, }, - ...triggerChars, + ...completionTriggerChars, + ), + ); +} + +function requestForwardSignatureHelp(ngContext: NgContext) { + ngContext.vscodeContext.subscriptions.push( + languages.registerSignatureHelpProvider( + [ + // 这个只有 ts 才能起作用,js 没有类型信息 + { scheme: 'file', language: 'typescript' }, + ], + { + async provideSignatureHelp(document, position, _, ctx) { + if (!ngContext.isNgProjectDocument(document)) { + return; + } + + const vDocText = resolveVirtualDocText(document, position); + if (!vDocText) { + return; + } + + const vDocUri = prepareVirtualDocument(document, vDocText); + const info = await commands.executeCommand( + 'vscode.executeSignatureHelpProvider', + vDocUri, + position, + ctx.triggerCharacter, + ); + return info; + }, + }, + ...signatureHelpTriggerChars, ), ); } diff --git a/packages/ng-helper-vscode/src/features/signatureHelp/index.ts b/packages/ng-helper-vscode/src/features/signatureHelp/index.ts new file mode 100644 index 00000000..d9b89496 --- /dev/null +++ b/packages/ng-helper-vscode/src/features/signatureHelp/index.ts @@ -0,0 +1,158 @@ +import { getCursorAtInfo, type CursorAtAttrValueInfo, type CursorAtTemplateInfo } from '@ng-helper/shared/lib/cursorAt'; +import { getActiveParameterIndex, getFnCallNode } from '@ng-helper/shared/lib/fnCallNgSyntax'; +import type { NgHoverInfo } from '@ng-helper/shared/lib/plugin'; +import { + languages, + SignatureHelp, + SignatureInformation, + ParameterInformation, + type TextDocument, + type Position, + type CancellationToken, +} from 'vscode'; + +import { checkCancellation, createCancellationTokenSource, withTimeoutAndMeasure } from '../../asyncUtils'; +import type { NgContext } from '../../ngContext'; +import { buildCursor, normalizePath } from '../../utils'; +import { onTypeHover } from '../hover/utils'; +import { getControllerNameInfo, isComponentHtml } from '../utils'; + +export const triggerChars = ['(', ',']; + +export function registerSignatureHelp(ngContext: NgContext): void { + ngContext.vscodeContext.subscriptions.push( + languages.registerSignatureHelpProvider( + 'html', + { + async provideSignatureHelp(document, position, token, _context): Promise { + if (!ngContext.isNgProjectDocument(document)) { + return; + } + + const cancelTokenSource = createCancellationTokenSource(token); + return await withTimeoutAndMeasure( + 'provideSignatureHelp', + () => provideSignatureHelp({ document, position, cancelToken: token, ngContext }), + { cancelTokenSource }, + ); + }, + }, + ...triggerChars, + ), + ); +} + +async function provideSignatureHelp({ + document, + position, + ngContext, + cancelToken, +}: { + document: TextDocument; + position: Position; + ngContext: NgContext; + cancelToken: CancellationToken; +}) { + const cursorAtInfo = getCursorAtInfo(document.getText(), buildCursor(document, position), { + filePath: normalizePath(document.uri.fsPath), // 注意:这里的处理方式要一致,否则缓存会失效 + version: document.version, + }); + + if (cursorAtInfo.type !== 'template' && cursorAtInfo.type !== 'attrValue') { + return; + } + + const cursorAt = cursorAtInfo.relativeCursorAt; + const ngExprStr = cursorAtInfo.type === 'template' ? cursorAtInfo.template : cursorAtInfo.attrValue; + const callNode = getFnCallNode(ngExprStr, cursorAt); + if (!callNode) { + return; + } + + const activeParameterIndex = getActiveParameterIndex(callNode, cursorAt); + if (activeParameterIndex === -1) { + return; + } + + const newCursorAtInfo = { ...cursorAtInfo }; + // 将光标移到函数名字上 + newCursorAtInfo.relativeCursorAt -= cursorAt - (callNode.callee.end - 1); + + const hoverInfo = await getMethodHoverInfo({ + document, + ngContext, + cursorAtInfo: newCursorAtInfo, + cancelToken, + }); + if (!hoverInfo) { + return; + } + + checkCancellation(cancelToken); + + return buildSignatureHelp(hoverInfo, activeParameterIndex); +} + +async function getMethodHoverInfo({ + document, + ngContext, + cursorAtInfo, + cancelToken, +}: { + document: TextDocument; + cursorAtInfo: CursorAtAttrValueInfo | CursorAtTemplateInfo; + ngContext: NgContext; + cancelToken: CancellationToken; +}) { + return await onTypeHover({ + type: 'hover', + document, + cursorAtInfo, + // eslint-disable-next-line + onHoverFilterName: async () => undefined, + onHoverLocalType: () => undefined, + onHoverType: async (scriptFilePath, contextString, cursorAt, hoverPropName) => { + checkCancellation(cancelToken); + + if (isComponentHtml(document)) { + return await ngContext.rpcApi.getComponentTypeHoverApi({ + cancelToken, + params: { fileName: scriptFilePath, contextString, cursorAt, hoverPropName }, + }); + } + + const ctrlInfo = getControllerNameInfo(cursorAtInfo.context); + if (ctrlInfo) { + return await ngContext.rpcApi.getControllerTypeHoverApi({ + cancelToken, + params: { fileName: scriptFilePath, contextString, cursorAt, hoverPropName, ...ctrlInfo }, + }); + } + }, + }); +} + +function buildSignatureHelp(hoverInfo: NgHoverInfo, activeParameterIndex: number): SignatureHelp | undefined { + if (!hoverInfo.isMethod) { + return; + } + + // remove '(method) ' from start + hoverInfo.formattedTypeString = hoverInfo.formattedTypeString.slice('(method) '.length); + + const signature = new SignatureInformation(hoverInfo.formattedTypeString, hoverInfo.document); + signature.parameters = + hoverInfo.parameters?.map((p) => { + const paramStr = `${p.name}: ${p.typeString}`; + const start = hoverInfo.formattedTypeString.indexOf(paramStr); + const end = start + paramStr.length; + return new ParameterInformation([start, end], p.document); + }) ?? []; + + const sigHelp = new SignatureHelp(); + sigHelp.signatures = [signature]; + sigHelp.activeParameter = activeParameterIndex; + sigHelp.activeSignature = 0; // 目前不考虑重载 + + return sigHelp; +} diff --git a/packages/ng-helper-vscode/tests/e2e/__snapshots__/completion.test.js.snap b/packages/ng-helper-vscode/tests/e2e/__snapshots__/completion.test.js.snap index 5fb58bf7..7038272c 100644 --- a/packages/ng-helper-vscode/tests/e2e/__snapshots__/completion.test.js.snap +++ b/packages/ng-helper-vscode/tests/e2e/__snapshots__/completion.test.js.snap @@ -1132,10 +1132,7 @@ Array [ "detail": "(method) onDragDrop: (p: { dragDropTarget: DragDropTarget; }) => void", "documentation": undefined, "filterText": undefined, - "insertText": t { - "e": 1, - "value": "onDragDrop$1(\${2:p})", - }, + "insertText": "onDragDrop", "kind": "Method", "label": "onDragDrop", "preselect": undefined, @@ -1153,6 +1150,17 @@ Array [ "sortText": "004", "textEdit": undefined, }, + Object { + "detail": "(method) fmt: (s: string, option: { lower: boolean; upper: boolean; }) => string", + "documentation": undefined, + "filterText": undefined, + "insertText": "fmt", + "kind": "Method", + "label": "fmt", + "preselect": undefined, + "sortText": "005", + "textEdit": undefined, + }, ] `; diff --git a/packages/ng-helper-vscode/tests/e2e/__snapshots__/signtureHelp.test.js.snap b/packages/ng-helper-vscode/tests/e2e/__snapshots__/signtureHelp.test.js.snap new file mode 100644 index 00000000..d0260d56 --- /dev/null +++ b/packages/ng-helper-vscode/tests/e2e/__snapshots__/signtureHelp.test.js.snap @@ -0,0 +1,138 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SignatureHelp component html trigger by "(" 1`] = ` +Object { + "activeParameter": 0, + "activeSignature": 0, + "signatures": Array [ + Object { + "activeParameter": undefined, + "documentation": undefined, + "label": "fmt: (s: string, option: { + lower: boolean; + upper: boolean; +}) => string", + "parameters": Array [ + Object { + "documentation": undefined, + "label": Array [ + 6, + 15, + ], + }, + Object { + "documentation": undefined, + "label": Array [ + 17, + 68, + ], + }, + ], + }, + ], +} +`; + +exports[`SignatureHelp component html trigger by "," 1`] = ` +Object { + "activeParameter": 1, + "activeSignature": 0, + "signatures": Array [ + Object { + "activeParameter": undefined, + "documentation": undefined, + "label": "fmt: (s: string, option: { + lower: boolean; + upper: boolean; +}) => string", + "parameters": Array [ + Object { + "documentation": undefined, + "label": Array [ + 6, + 15, + ], + }, + Object { + "documentation": undefined, + "label": Array [ + 17, + 68, + ], + }, + ], + }, + ], +} +`; + +exports[`SignatureHelp controller html no argument 1`] = ` +Object { + "activeParameter": 0, + "activeSignature": 0, + "signatures": Array [ + Object { + "activeParameter": undefined, + "documentation": undefined, + "label": "getInfo: () => string", + "parameters": Array [], + }, + ], +} +`; + +exports[`SignatureHelp controller html one argument 1`] = ` +Object { + "activeParameter": 0, + "activeSignature": 0, + "signatures": Array [ + Object { + "activeParameter": undefined, + "documentation": undefined, + "label": "buildDesc: (index: number) => string", + "parameters": Array [ + Object { + "documentation": undefined, + "label": Array [ + 12, + 25, + ], + }, + ], + }, + ], +} +`; + +exports[`SignatureHelp inline html 1`] = ` +Object { + "activeParameter": 1, + "activeSignature": 0, + "signatures": Array [ + Object { + "activeParameter": undefined, + "documentation": undefined, + "label": "fmt: (s: string, option: { + lower: boolean; + upper: boolean; +}) => string", + "parameters": Array [ + Object { + "documentation": undefined, + "label": Array [ + 6, + 15, + ], + }, + Object { + "documentation": undefined, + "label": Array [ + 17, + 68, + ], + }, + ], + }, + ], +} +`; diff --git a/packages/ng-helper-vscode/tests/e2e/completion.test.ts b/packages/ng-helper-vscode/tests/e2e/completion.test.ts index bb20cd0d..1a9b0074 100644 --- a/packages/ng-helper-vscode/tests/e2e/completion.test.ts +++ b/packages/ng-helper-vscode/tests/e2e/completion.test.ts @@ -228,7 +228,7 @@ describe('Completion', () => { // ctrl await testCompletion({ filePath: DRAG_SOURCE_COMPONENT_TS_PATH, - position: new vscode.Position(33, 11), + position: new vscode.Position(34, 11), itemsFilter: (item) => item.detail === '[ng-helper]', // 注意:这里不能有 triggerChar,否则结果为空,因为内部实现如果有 triggerChar 就直接返回了。 }); @@ -236,7 +236,7 @@ describe('Completion', () => { // ctrl.* await testCompletion({ filePath: DRAG_SOURCE_COMPONENT_TS_PATH, - position: new vscode.Position(35, 15), + position: new vscode.Position(36, 15), triggerChar: '.', }); }); @@ -244,7 +244,7 @@ describe('Completion', () => { it('filter', async () => { await testCompletion({ filePath: DRAG_SOURCE_COMPONENT_TS_PATH, - position: new vscode.Position(37, 32), + position: new vscode.Position(38, 32), ignoreIsIncomplete: true, itemsFilter: (item) => item.detail?.startsWith('(filter)') ?? false, }); diff --git a/packages/ng-helper-vscode/tests/e2e/definition.test.ts b/packages/ng-helper-vscode/tests/e2e/definition.test.ts index ac15c9b3..37fc8e85 100644 --- a/packages/ng-helper-vscode/tests/e2e/definition.test.ts +++ b/packages/ng-helper-vscode/tests/e2e/definition.test.ts @@ -99,27 +99,27 @@ describe('Definition', () => { describe('inline html', () => { it('component name', async () => { - await testDefinition(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(29, 10)); + await testDefinition(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(30, 10)); }); it('component attr', async () => { - await testDefinition(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(29, 24)); + await testDefinition(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(30, 24)); }); it('directive name', async () => { - await testDefinition(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(30, 15)); + await testDefinition(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(31, 15)); }); it('directive attr', async () => { - await testDefinition(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(30, 27)); + await testDefinition(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(31, 27)); }); it('type', async () => { - await testDefinition(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(31, 71)); + await testDefinition(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(32, 71)); }); it('filter', async () => { - await testDefinition(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(30, 62)); + await testDefinition(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(31, 62)); }); }); }); diff --git a/packages/ng-helper-vscode/tests/e2e/hover.test.ts b/packages/ng-helper-vscode/tests/e2e/hover.test.ts index 004e74c3..86d25ae3 100644 --- a/packages/ng-helper-vscode/tests/e2e/hover.test.ts +++ b/packages/ng-helper-vscode/tests/e2e/hover.test.ts @@ -123,32 +123,32 @@ describe('Hover', () => { describe('inline html', () => { it('hover component name', async () => { - await testHover(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(29, 10)); + await testHover(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(30, 10)); }); it('hover component attr', async () => { - await testHover(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(29, 24)); + await testHover(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(30, 24)); }); it('hover directive name', async () => { - await testHover(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(30, 15)); + await testHover(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(31, 15)); }); it('hover directive attr', async () => { - await testHover(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(30, 27)); + await testHover(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(31, 27)); }); it('hover ng-*', async () => { // ng-modal - await testHover(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(30, 42)); + await testHover(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(31, 42)); }); it('hover type', async () => { - await testHover(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(31, 71)); + await testHover(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(32, 71)); }); it('hover filter', async () => { - await testHover(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(30, 62)); + await testHover(DRAG_SOURCE_COMPONENT_TS_PATH, new vscode.Position(31, 62)); }); }); }); diff --git a/packages/ng-helper-vscode/tests/e2e/signtureHelp.test.ts b/packages/ng-helper-vscode/tests/e2e/signtureHelp.test.ts new file mode 100644 index 00000000..1558f700 --- /dev/null +++ b/packages/ng-helper-vscode/tests/e2e/signtureHelp.test.ts @@ -0,0 +1,91 @@ +import { expect } from 'chai'; +import { describe, it, before } from 'mocha'; +import * as vscode from 'vscode'; + +import { + APP_PAGES_P2_HTML_PATH, + BAR_FOO_COMPONENT_HTML_PATH, + DRAG_SOURCE_COMPONENT_TS_PATH, + SIGNATURE_HELP_COMMAND, +} from '../testConstants'; +import { activate, setupChaiSnapshotPlugin, sleep } from '../testUtils'; + +describe('SignatureHelp', () => { + setupChaiSnapshotPlugin(); + + before(async () => { + await activate(); + }); + + describe('component html', () => { + it('trigger by "("', async () => { + await testSignatureHelp({ + filePath: BAR_FOO_COMPONENT_HTML_PATH, + position: new vscode.Position(7, 16), + triggerChar: '(', + }); + }); + + it('trigger by ","', async () => { + await testSignatureHelp({ + filePath: BAR_FOO_COMPONENT_HTML_PATH, + position: new vscode.Position(9, 25), + triggerChar: ',', + }); + }); + }); + + describe('controller html', () => { + it('no argument', async () => { + await testSignatureHelp({ + filePath: APP_PAGES_P2_HTML_PATH, + position: new vscode.Position(10, 31), + triggerChar: '(', + }); + }); + + it('one argument', async () => { + await testSignatureHelp({ + filePath: APP_PAGES_P2_HTML_PATH, + position: new vscode.Position(12, 34), + triggerChar: '(', + }); + }); + }); + + it('inline html', async () => { + await testSignatureHelp({ + filePath: DRAG_SOURCE_COMPONENT_TS_PATH, + position: new vscode.Position(40, 30), + triggerChar: ',', + }); + }); +}); + +async function testSignatureHelp({ + filePath, + position, + triggerChar, + waitSeconds, +}: { + filePath: string; + position: vscode.Position; + triggerChar?: string; + waitSeconds?: number; +}) { + // show the document + await vscode.window.showTextDocument(vscode.Uri.file(filePath)); + if (waitSeconds) { + await sleep(waitSeconds * 1000); + } + + // get completion info + const info = await vscode.commands.executeCommand( + SIGNATURE_HELP_COMMAND, + vscode.Uri.file(filePath), + position, + triggerChar, + ); + + expect(info).toMatchSnapshot(); +} diff --git a/packages/ng-helper-vscode/tests/fixtures/app/components/bar-foo/bar-foo.component.html b/packages/ng-helper-vscode/tests/fixtures/app/components/bar-foo/bar-foo.component.html index 47cc7d4f..0dcb3c89 100644 --- a/packages/ng-helper-vscode/tests/fixtures/app/components/bar-foo/bar-foo.component.html +++ b/packages/ng-helper-vscode/tests/fixtures/app/components/bar-foo/bar-foo.component.html @@ -3,4 +3,8 @@
{{ctrl.foo | translate}}
{{item.name}}({{$index}})-{{$first.name}} -
\ No newline at end of file + + +
{{ctrl.fmt()}}
+ +
{{ctrl.fmt(ctrl.foo,)}}
\ No newline at end of file diff --git a/packages/ng-helper-vscode/tests/fixtures/app/components/bar-foo/bar-foo.component.ts b/packages/ng-helper-vscode/tests/fixtures/app/components/bar-foo/bar-foo.component.ts index d7360fe5..33b88dae 100644 --- a/packages/ng-helper-vscode/tests/fixtures/app/components/bar-foo/bar-foo.component.ts +++ b/packages/ng-helper-vscode/tests/fixtures/app/components/bar-foo/bar-foo.component.ts @@ -21,6 +21,10 @@ namespace app.components { { id: 5, name: 'item-3' }, ], }; + fmt = (s: string, option: { lower: boolean, upper: boolean }) => { + // ... impl + return s; + } } angular.module('app.components').component('barFoo', { diff --git a/packages/ng-helper-vscode/tests/fixtures/app/components/drag-source/drag-source.component.ts b/packages/ng-helper-vscode/tests/fixtures/app/components/drag-source/drag-source.component.ts index b7b3dfb7..1e34421c 100644 --- a/packages/ng-helper-vscode/tests/fixtures/app/components/drag-source/drag-source.component.ts +++ b/packages/ng-helper-vscode/tests/fixtures/app/components/drag-source/drag-source.component.ts @@ -23,6 +23,7 @@ namespace app.directives { dragDropTarget: DragDropTarget; }) => void; num!: number; + fmt!: (s: string, option: { lower: boolean, upper: boolean }) => string; } angular.module('app.directives').component('dragSource', { @@ -36,6 +37,8 @@ namespace app.directives { {{ctrl.}}
+ +
{{ctrl.fmt('xyz',)}}
`, bindings : { disabledDrag : ' {{i}} + +
+ +
\ No newline at end of file diff --git a/packages/ng-helper-vscode/tests/fixtures/app/pages/p2/p2.ts b/packages/ng-helper-vscode/tests/fixtures/app/pages/p2/p2.ts index bacf2561..4181e8ae 100644 --- a/packages/ng-helper-vscode/tests/fixtures/app/pages/p2/p2.ts +++ b/packages/ng-helper-vscode/tests/fixtures/app/pages/p2/p2.ts @@ -16,6 +16,12 @@ namespace app.pages { { id: 5, name: 'item-3' }, ], }; + getInfo() { + return ''; + } + buildDesc(index: number) { + return ''; + } } angular.module('app.pages').controller('P2Controller', P2Controller); diff --git a/packages/ng-helper-vscode/tests/testConstants.ts b/packages/ng-helper-vscode/tests/testConstants.ts index bb7e3657..6b41f833 100644 --- a/packages/ng-helper-vscode/tests/testConstants.ts +++ b/packages/ng-helper-vscode/tests/testConstants.ts @@ -11,6 +11,7 @@ export const HOVER_COMMAND = 'vscode.executeHoverProvider'; export const DEFINITION_COMMAND = 'vscode.executeDefinitionProvider'; export const SEMANTIC_TOKENS_LEGEND_COMMAND = 'vscode.provideDocumentSemanticTokensLegend'; export const SEMANTIC_TOKENS_COMMAND = 'vscode.provideDocumentSemanticTokens'; +export const SIGNATURE_HELP_COMMAND = 'vscode.executeSignatureHelpProvider'; // ---- project path ---- // 注意这个路径要按照编译后的文件位置来写,编译后文件在 tests/dist 目录下。 diff --git a/packages/shared/lib/fnCallNgSyntax.ts b/packages/shared/lib/fnCallNgSyntax.ts new file mode 100644 index 00000000..c13ba33a --- /dev/null +++ b/packages/shared/lib/fnCallNgSyntax.ts @@ -0,0 +1,270 @@ +import { NgControllerProgram } from '@ng-helper/ng-parser/src/parser/ngControllerNode'; +import { NgRepeatProgram } from '@ng-helper/ng-parser/src/parser/ngRepeatNode'; +import type { + ArrayLiteralExpression, + AssignExpression, + BinaryExpression, + CallExpression, + ConditionalExpression, + ElementAccess, + ElementAccessExpression, + ExpressionStatement, + FilterExpression, + GroupExpression, + Identifier, + Literal, + ObjectLiteralExpression, + Program, + PropertyAccessExpression, + PropertyAssignment, + UnaryExpression, +} from '@ng-helper/ng-parser/src/parser/node'; +import type { INodeVisitor, Location, Programs } from '@ng-helper/ng-parser/src/types'; + +import { ngParse } from './ngParse'; + +class FnCallNgSyntaxVisitor implements INodeVisitor { + private cursorAt = 0; + + getCursorAtFnCallSyntax(program: Programs, cursorAt: number): CallExpression | undefined { + if (cursorAt < 0 || cursorAt >= program.source.length) { + return; + } + + this.cursorAt = cursorAt; + + if (program instanceof NgControllerProgram) { + return; + } else if (program instanceof NgRepeatProgram) { + return this.getNgRepeatFnCallSyntax(program); + } + + return program.accept(this); + } + + private getNgRepeatFnCallSyntax(program: NgRepeatProgram): CallExpression | undefined { + if (program.config && this.isAt(program.config.items)) { + return program.config.items.accept(this); + } + } + + private isAt(node: Location): boolean { + return isAt(node, this.cursorAt); + } + + visitProgram(node: Program): CallExpression | undefined { + for (const statement of node.statements) { + if (this.isAt(statement)) { + return statement.accept(this); + } + } + } + + visitExpressionStatement(node: ExpressionStatement): CallExpression | undefined { + if (this.isAt(node.expression)) { + return node.expression.accept(this); + } + } + + visitFilterExpression(node: FilterExpression): CallExpression | undefined { + if (!this.isAt(node)) { + return; + } + + if (this.isAt(node.name)) { + return; + } else if (this.isAt(node.input)) { + return node.input.accept(this); + } else { + for (const arg of node.args) { + if (this.isAt(arg)) { + return arg.accept(this); + } + } + } + } + + visitAssignExpression(node: AssignExpression): CallExpression | undefined { + if (!this.isAt(node)) { + return; + } + + if (this.isAt(node.left)) { + return node.left.accept(this); + } else if (this.isAt(node.right)) { + return node.right.accept(this); + } + } + + visitConditionalExpression(node: ConditionalExpression): CallExpression | undefined { + if (!this.isAt(node)) { + return; + } + + if (this.isAt(node.condition)) { + return node.condition.accept(this); + } else if (this.isAt(node.whenTrue)) { + return node.whenTrue.accept(this); + } else if (this.isAt(node.whenFalse)) { + return node.whenFalse.accept(this); + } + } + + visitBinaryExpression(node: BinaryExpression): CallExpression | undefined { + if (!this.isAt(node)) { + return; + } + + if (this.isAt(node.left)) { + return node.left.accept(this); + } else if (this.isAt(node.right)) { + return node.right.accept(this); + } + } + + visitUnaryExpression(node: UnaryExpression): CallExpression | undefined { + if (!this.isAt(node)) { + return; + } + + if (this.isAt(node.operand)) { + return node.operand.accept(this); + } + } + + visitArrayLiteralExpression(node: ArrayLiteralExpression): CallExpression | undefined { + if (!this.isAt(node)) { + return; + } + + for (const element of node.elements) { + if (this.isAt(element)) { + return element.accept(this); + } + } + } + + visitObjectLiteralExpression(node: ObjectLiteralExpression): CallExpression | undefined { + if (!this.isAt(node)) { + return; + } + + for (const property of node.properties) { + if (this.isAt(property)) { + return property.accept(this); + } + } + } + + visitPropertyAssignment(node: PropertyAssignment): CallExpression | undefined { + if (!this.isAt(node)) { + return; + } + + if (this.isAt(node.property)) { + return node.property.accept(this); + } else if (this.isAt(node.initializer)) { + return node.initializer.accept(this); + } + } + + visitElementAccess(node: ElementAccess): CallExpression | undefined { + if (this.isAt(node.expression)) { + return node.expression.accept(this); + } + } + + visitPropertyAccessExpression(node: PropertyAccessExpression): CallExpression | undefined { + if (!this.isAt(node)) { + return; + } + + if (this.isAt(node.parent)) { + return node.parent.accept(this); + } + } + + visitElementAccessExpression(node: ElementAccessExpression): CallExpression | undefined { + if (!this.isAt(node)) { + return; + } + + if (this.isAt(node.elementExpression)) { + return node.elementExpression.accept(this); + } else if (this.isAt(node.parent)) { + return node.parent.accept(this); + } + } + + visitCallExpression(node: CallExpression): CallExpression | undefined { + if (!this.isAt(node)) { + return; + } + + for (const arg of node.args) { + if (this.isAt(arg)) { + const n = arg.accept(this); + if (n) { + return n; + } + } + } + + return node; + } + + visitIdentifier(_node: Identifier): CallExpression | undefined { + return; + } + + visitLiteral(_node: Literal): CallExpression | undefined { + return; + } + + visitGroupExpression(node: GroupExpression): CallExpression | undefined { + if (this.isAt(node.expression)) { + return node.expression.accept(this); + } + } +} + +function isAt(node: Location, cursorAt: number): boolean { + return cursorAt >= node.start && cursorAt < node.end; +} + +const fnCallNgSyntaxVisitor = new FnCallNgSyntaxVisitor(); + +export function getFnCallNode( + ngExprStr: string, + cursorAt: number, + attrName?: 'ng-repeat' | 'ng-controller', +): CallExpression | undefined { + if (!ngExprStr || typeof ngExprStr !== 'string') { + return; + } + + const program = ngParse(ngExprStr, attrName); + + return fnCallNgSyntaxVisitor.getCursorAtFnCallSyntax(program, cursorAt); +} + +export function getActiveParameterIndex(callNode: CallExpression, cursorAt: number): number { + let activeIndex = -1; + if (cursorAt >= callNode.callee.end) { + const args = callNode.args; + + if (args.length === 0) { + activeIndex = 0; + } else { + let i = 0; + while (i < args.length && cursorAt >= args[i].end) { + i++; + } + if (i >= args.length) { + i = args.length - 1; + } + activeIndex = i; + } + } + return activeIndex; +} diff --git a/packages/shared/lib/minNgSyntax.ts b/packages/shared/lib/minNgSyntax.ts index 6b1540d7..6b35b36d 100644 --- a/packages/shared/lib/minNgSyntax.ts +++ b/packages/shared/lib/minNgSyntax.ts @@ -207,7 +207,7 @@ class MinNgSyntaxVisitor implements INodeVisitor; } export interface NgComponentNameInfo { diff --git a/packages/shared/tests/fnCallNgSyntax.spec.ts b/packages/shared/tests/fnCallNgSyntax.spec.ts new file mode 100644 index 00000000..6ba2ec0f --- /dev/null +++ b/packages/shared/tests/fnCallNgSyntax.spec.ts @@ -0,0 +1,146 @@ +import { getActiveParameterIndex, getFnCallNode } from '../lib/fnCallNgSyntax'; + +describe('getFnCallNode', () => { + it.each([ + // Literal tests + ['1', undefined], + ["'a'", undefined], + + // Identifier tests + ['$event', undefined], + + // Property access tests + ['ctrl.', undefined], + ['ctrl.a[ctrl.f(', 'ctrl.f('], + ['ctrl.a[ctrl.prefix + ctrl.f(', 'ctrl.f('], + + // Array tests + ['[ctrl.b.c(', 'ctrl.b.c('], + ['[1, 1 + ctrl.f(', 'ctrl.f('], + + // Method call tests + ['ctrl.a(ctrl.b.c.', 'ctrl.a(ctrl.b.c.'], + ['ctrl.a(1, a = ctrl.', 'ctrl.a(1, a = ctrl.'], + ['ctrl.a(1, ctrl.b.c).', undefined], + ['ctrl.a(1, 1 + ctrl.b.c(', 'ctrl.b.c('], + + // Conditional expression tests + ['ctrl.a ? 1 : 2', undefined], + ['ctrl.a ? ctrl.b : x ? x : ctrl.c(', 'ctrl.c('], + + // Unary expression tests + ['!+ctrl.a.', undefined], + ['!ctrl.a(', 'ctrl.a('], + + // Binary expression tests + ['ctrl.a = ctrl.b(', 'ctrl.b('], + ['ctrl.a && ctrl.b(', 'ctrl.b('], + ['ctrl.a && ctrl.b()', 'ctrl.b()'], + + // Group expression tests + ['(1 + ctrl.a(', 'ctrl.a('], + + // Object literal tests + ['({ a:ctrl.b, b:ctrl.c(', 'ctrl.c('], + + // Filter tests + ['ctrl.a | f1', undefined], + ['ctrl.a | f2 :1 :a(', 'a('], + + // Multiple statements tests + ['ctrl.a = ctrl.b.c; x = ctrl.d(', 'ctrl.d('], + ])('test input on end for signatureHelp: %s => %s', (expr, expectedText) => { + testSyntax(expr, expr.length - 1, expectedText); + }); + + it.each([ + // Element access tests + ['x[c()].m + 1', 4, 'c()'], + ['x[c()].m + 1', 5, undefined], + + // Array tests + ['[x(1), 1]', 2, 'x(1)'], + ['[x(1), 1]', 5, undefined], + + // Method call tests + ['x(1, c(5-))', 2, 'x(1, c(5-))'], + ['x(1, c(5-))', 7, 'c(5-)'], + + // Conditional expression tests + ['c() ? 1 : 2', 3, undefined], + ['c() ? 1 : 2', 1, 'c()'], + ['c() ? x(b) : 2', 6, 'x(b)'], + ['c() ? 1 : x(b)', 11, 'x(b)'], + + // Binary expression tests + ['x(6) > ctrl.b', 2, 'x(6)'], + ['1 + x(6, 7) === ctrl.', 4, 'x(6, 7)'], + + // Group expression tests + ['((x. + b()) / ctrl.c)', 8, 'b()'], + + // Object literal tests + ['({ a: x(), b:ctrl.c }', 6, 'x()'], + ['({ [1 + x()]:ctrl., b:ctrl.c }', 9, 'x()'], + + // Filter tests + ['x | f1 | f2', 5, undefined], + ['x | f1 :a = m ? c() : d', 17, 'c()'], + + // Multiple statements tests + ['x = c(); ctrl.a = ctrl.b.c;', 5, 'c()'], + ])('test input on middle for signatureHelp: %s', (expr, cursorAt, expectedText) => { + testSyntax(expr, cursorAt, expectedText); + }); + + // NgRepeat tests + it.each([ + ['item in items', 0, undefined], + ['item in x()', 10, 'x()'], + ['item in items track by item.id', 29, undefined], + ['item in items as alias', 20, undefined], + ])('test ng-repeat attributes: %s', (expr, cursorAt, expectedText) => { + testSyntax(expr, cursorAt, expectedText); + }); + + // NgController tests + it.each([ + ['MyController as ctrl', 0, undefined], + ['MyController as ctrl', 19, undefined], + ])('test ng-controller attributes: %s', (expr, cursorAt, expectedText) => { + testSyntax(expr, cursorAt, expectedText); + }); + + function testSyntax(expr: string, cursorAt: number, expectedText?: string) { + const result = getFnCallNode(expr, cursorAt); + if (expectedText) { + expect(expr.slice(result?.start, result?.end)).toEqual(expectedText); + } else { + expect(result).toBeUndefined(); + } + } +}); + +describe('getActiveParameterIndex', () => { + it.each([ + ['x()', 0, -1], + ['x()', 1, 0], + ['x()', 2, 0], + ['x(1, 2, 3)', 1, 0], + ['x(1, 2, 3)', 2, 0], + ['x(1, 2, 3)', 3, 1], + ['x(1, 2, 3)', 4, 1], + ['x(1, 2, 3)', 5, 1], + ['x(1, 2, 3)', 6, 2], + ['x(1, 2, 3)', 7, 2], + ['x(1, 2, 3)', 8, 2], + ['x(1, 2, 3)', 9, 2], + ])('test %s, %s => %s', (expr, cursorAt, expectedText) => { + testSyntax(expr, cursorAt, expectedText); + }); + + function testSyntax(expr: string, cursorAt: number, expectedActiveParamIndex: number) { + const result = getActiveParameterIndex(getFnCallNode(expr, cursorAt)!, cursorAt); + expect(result).toBe(expectedActiveParamIndex); + } +}); diff --git a/packages/typescript-plugin/src/hover/utils.ts b/packages/typescript-plugin/src/hover/utils.ts index a653bb87..0155a0b5 100644 --- a/packages/typescript-plugin/src/hover/utils.ts +++ b/packages/typescript-plugin/src/hover/utils.ts @@ -40,10 +40,26 @@ export function buildHoverInfo({ parentType?: ts.Type; }): NgHoverInfo { let typeKind = 'property'; + let parameters: + | Array<{ + name: string; + typeString: string; + document: string; + }> + | undefined = undefined; if (targetType.isClass()) { typeKind = 'class'; } else if (targetType.getCallSignatures().length > 0) { typeKind = 'method'; + const signature = targetType.getCallSignatures()[0]; + parameters = signature.parameters.map((x) => { + const type = ctx.typeChecker.getTypeOfSymbolAtLocation(x, x.valueDeclaration ?? x.declarations![0]); + return { + name: x.name, + typeString: formatTypeString(ctx, type), + document: x.getDocumentationComment(ctx.typeChecker).toString(), + }; + }); } let document = ''; @@ -57,6 +73,8 @@ export function buildHoverInfo({ return { formattedTypeString: `(${typeKind}) ${name}: ${formatTypeString(ctx, targetType)}`, document, + isMethod: typeKind === 'method', + parameters, }; }