diff --git a/.vscode/launch.json b/.vscode/launch.json index 6411a970..d80e5e7c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,6 +14,9 @@ "${workspaceFolder}/out/**/*.js" ], "sourceMaps": true, + "cascadeTerminateToConfigurations": [ + "Attach to TS Server", + ], "env": { "TSS_DEBUG": "9223", "TSS_REMOTE_DEBUG": "9223" @@ -25,6 +28,7 @@ "request": "attach", "restart": true, "port": 9223, + "customDescriptionGenerator": "function (def) { return this?.__debugKind || this?.__debugFlags || def }", "sourceMaps": true, "outFiles": [ "${workspaceFolder}/out/**/*.js" @@ -49,6 +53,13 @@ "Launch Extension", "Attach to TS Server" ] - } + }, + { + "name": "Extension + Volar", + "configurations": [ + "Launch Extension", + "Attach to Vue Semantic Server" + ] + }, ] } diff --git a/README.MD b/README.MD index d6dec33e..628863e3 100644 --- a/README.MD +++ b/README.MD @@ -16,7 +16,7 @@ TOC: - [Contributed Code Actions](#contributed-code-actions) - [Even Even More](#even-even-more) -> *Note*: You can disable all optional features with `> Disable All Optional Features` setting right after install. +> *Note*: You can disable all optional features with `> Disable All Optional Features` command right after install. > > *Note*: Visit website for list of recommended settings: @@ -42,7 +42,7 @@ Also is not supported in the web. 90% work done in this extension highly improves completions experience! -### Strict Emmet +### Strict JSX Emmet (*enabled by default*) when react langs are in `emmet.excludeLanguages` diff --git a/package.json b/package.json index 06d57dcb..eb74a4ce 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,10 @@ { "command": "replaceGlobalTypescriptWithLocalVersion", "title": "Replace Global Typescript with Local Version" + }, + { + "command": "getArgumentReferencesFromCurrentParameter", + "title": "Get Argument References from Current Parameter" } ], "keybindings": [ @@ -201,4 +205,4 @@ "runTest": false } } -} \ No newline at end of file +} diff --git a/playground.ts b/playground.ts index 484633a6..5a47d3ef 100644 --- a/playground.ts +++ b/playground.ts @@ -2,7 +2,13 @@ import ts from 'typescript/lib/tsserverlibrary' import { createLanguageService } from './typescript/src/dummyLanguageService' -let testString = 'const a: {/** @default test */a: 5} | {b: 6, /** yes */a: 9} = null as any;\nif ("||" in a) {}' +globalThis.ts = ts + +let testString = /* ts */ ` +const b = () => 5 +const a = b()|| as +new Promise() +` const replacement = '||' const pos = testString.indexOf(replacement) testString = testString.slice(0, pos) + testString.slice(pos + replacement.length) @@ -16,7 +22,7 @@ const sourceFile = program?.getSourceFile(filePath) if (!program || !sourceFile) throw new Error('No source file') const typeChecker = program.getTypeChecker() -const node = findChildContainingPosition(ts, sourceFile, pos) +let node = findChildContainingPosition(ts, sourceFile, pos) if (!node) throw new Error('No node') const type = typeChecker.getTypeAtLocation(node) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d732cc0b..a62fb5b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -715,7 +715,7 @@ packages: dependencies: '@types/http-cache-semantics': 4.0.1 '@types/keyv': 3.1.4 - '@types/node': 16.11.21 + '@types/node': 16.18.3 '@types/responselike': 1.0.0 dev: true @@ -777,7 +777,7 @@ packages: /@types/keyv@3.1.4: resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} dependencies: - '@types/node': 16.11.21 + '@types/node': 16.18.3 dev: true /@types/lodash@4.14.182: @@ -817,7 +817,7 @@ packages: /@types/responselike@1.0.0: resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==} dependencies: - '@types/node': 16.11.21 + '@types/node': 16.18.3 dev: true /@types/semver@7.3.13: @@ -3450,7 +3450,6 @@ packages: /graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - dev: false /graceful-fs@4.2.9: resolution: {integrity: sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==} @@ -3976,7 +3975,7 @@ packages: dependencies: universalify: 2.0.0 optionalDependencies: - graceful-fs: 4.2.9 + graceful-fs: 4.2.11 /jsonify@0.0.0: resolution: {integrity: sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=} diff --git a/src/apiCommands.ts b/src/apiCommands.ts index 4ad98adf..a6523857 100644 --- a/src/apiCommands.ts +++ b/src/apiCommands.ts @@ -27,7 +27,7 @@ export const sharedApiRequest = async (type: TriggerCharacterCommand, { offset, if (!position) offset ??= document.offsetAt(activeTextEditor!.selection.active) + relativeOffset const requestOffset = offset ?? document.offsetAt(position!) const requestPos = position ?? document.positionAt(offset!) - const getData = async () => sendCommand(type, { document: document!, position: requestPos }) + const getData = async () => sendCommand(type, { document: document!, position: requestPos, inputOptions: {} }) const CACHE_UNDEFINED_TIMEOUT = 1000 if (cacheableCommands.has(type as any)) { const cacheEntry = operationsCache.get(type) diff --git a/src/codeActionProvider.ts b/src/codeActionProvider.ts index 5942530b..be25dc1e 100644 --- a/src/codeActionProvider.ts +++ b/src/codeActionProvider.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode' import { defaultJsSupersetLangsWithVue } from '@zardoy/vscode-utils/build/langs' import { registerExtensionCommand, showQuickPick, getExtensionSetting, getExtensionCommandId } from 'vscode-framework' import { compact } from '@zardoy/utils' -import { RequestResponseTypes, RequestOptionsTypes } from '../typescript/src/ipcTypes' +import { RequestOutputTypes, RequestInputTypes } from '../typescript/src/ipcTypes' import { sendCommand } from './sendCommand' import { tsTextChangesToVscodeTextEdits, vscodeRangeToTs, tsTextChangesToVscodeSnippetTextEdits } from './util' @@ -26,7 +26,7 @@ export default () => { return } - const fixAllEdits = await sendCommand('getFixAllEdits', { + const fixAllEdits = await sendCommand('getFixAllEdits', { document, }) if (!fixAllEdits || token.isCancellationRequested) return @@ -89,16 +89,13 @@ export default () => { async resolveCodeAction(codeAction: ExtendedCodeAction, token) { const { document } = codeAction if (!document) throw new Error('Unresolved code action without document') - const result = await sendCommand( - 'getExtendedCodeActionEdits', - { - document, - inputOptions: { - applyCodeActionTitle: codeAction.title, - range: vscodeRangeToTs(document, codeAction.diagnostics?.length ? codeAction.diagnostics[0]!.range : codeAction.requestRange), - }, + const result = await sendCommand('getExtendedCodeActionEdits', { + document, + inputOptions: { + applyCodeActionTitle: codeAction.title, + range: vscodeRangeToTs(document, codeAction.diagnostics?.length ? codeAction.diagnostics[0]!.range : codeAction.requestRange), }, - ) + }) if (!result) throw new Error('No code action edits. Try debug.') const { edits = [], snippetEdits = [] } = result const workspaceEdit = new vscode.WorkspaceEdit() @@ -111,9 +108,9 @@ export default () => { }, }) - registerExtensionCommand('applyRefactor' as any, async (_, arg?: RequestResponseTypes['getTwoStepCodeActions']) => { + registerExtensionCommand('applyRefactor' as any, async (_, arg?: RequestOutputTypes['getTwoStepCodeActions']) => { if (!arg) return - let sendNextData: RequestOptionsTypes['twoStepCodeActionSecondStep']['data'] | undefined + let sendNextData: RequestInputTypes['twoStepCodeActionSecondStep']['data'] | undefined const { turnArrayIntoObject } = arg if (turnArrayIntoObject) { const { keysCount, totalCount, totalObjectCount } = turnArrayIntoObject @@ -151,7 +148,7 @@ export default () => { }) async function getPossibleTwoStepRefactorings(range: vscode.Range, document = vscode.window.activeTextEditor!.document) { - return sendCommand('getTwoStepCodeActions', { + return sendCommand('getTwoStepCodeActions', { document, position: range.start, inputOptions: { @@ -161,16 +158,13 @@ export default () => { } async function getSecondStepRefactoringData(range: vscode.Range, secondStepData?: any, document = vscode.window.activeTextEditor!.document) { - return sendCommand( - 'twoStepCodeActionSecondStep', - { - document, - position: range.start, - inputOptions: { - range: vscodeRangeToTs(document, range), - data: secondStepData, - }, + return sendCommand('twoStepCodeActionSecondStep', { + document, + position: range.start, + inputOptions: { + range: vscodeRangeToTs(document, range), + data: secondStepData, }, - ) + }) } } diff --git a/src/emmet.ts b/src/emmet.ts index da6b8bc3..1c2dd3ce 100644 --- a/src/emmet.ts +++ b/src/emmet.ts @@ -1,7 +1,6 @@ import * as vscode from 'vscode' import { compact } from '@zardoy/utils' import { getExtensionSetting, registerExtensionCommand } from 'vscode-framework' -import { EmmetResult } from '../typescript/src/ipcTypes' import { sendCommand } from './sendCommand' import { Configuration } from './configurationType' @@ -29,7 +28,7 @@ export const registerEmmet = async () => { const cursorOffset: number = document.offsetAt(position) if (context.triggerKind !== vscode.CompletionTriggerKind.TriggerForIncompleteCompletions || !lastStartOffset) { - const result = await sendCommand('emmet-completions', { document, position }) + const result = await sendCommand('emmet-completions', { document, position }) if (!result) { lastStartOffset = undefined return diff --git a/src/extension.ts b/src/extension.ts index b002aed3..8f037d37 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,10 +1,9 @@ /* eslint-disable @typescript-eslint/no-require-imports */ import * as vscode from 'vscode' import { defaultJsSupersetLangs } from '@zardoy/vscode-utils/build/langs' -import { Settings, extensionCtx, getExtensionSetting, getExtensionSettingId, registerExtensionCommand } from 'vscode-framework' +import { extensionCtx, getExtensionSetting, getExtensionSettingId } from 'vscode-framework' import { pickObj } from '@zardoy/utils' import { watchExtensionSettings } from '@zardoy/vscode-utils/build/settings' -import { ConditionalPick } from 'type-fest' import webImports from './webImports' import { sendCommand } from './sendCommand' import { registerEmmet } from './emmet' @@ -27,13 +26,21 @@ export const activateTsPlugin = (tsApi: { configurePlugin; onCompletionAccepted isActivated = true let webWaitingForConfigSync = false + const getResolvedConfig = () => { + const configuration = vscode.workspace.getConfiguration() + const config: any = { + ...configuration.get(process.env.IDS_PREFIX!), + editorSuggestInsertModeReplace: configuration.get('editor.suggest.insertMode') === 'replace', + } + mergeSettingsFromScopes(config, 'typescript', extensionCtx.extension.packageJSON) + return config + } + const syncConfig = () => { if (!tsApi) return console.log('sending configure request for typescript-essential-plugins') - const config: any = { ...vscode.workspace.getConfiguration().get(process.env.IDS_PREFIX!) } // todo implement language-specific settings - mergeSettingsFromScopes(config, 'typescript', extensionCtx.extension.packageJSON) - + const config = getResolvedConfig() tsApi.configurePlugin('typescript-essential-plugins', config) if (process.env.PLATFORM === 'node') { @@ -50,7 +57,7 @@ export const activateTsPlugin = (tsApi: { configurePlugin; onCompletionAccepted } vscode.workspace.onDidChangeConfiguration(async ({ affectsConfiguration }) => { - if (affectsConfiguration(process.env.IDS_PREFIX!)) { + if (affectsConfiguration(process.env.IDS_PREFIX!) || affectsConfiguration('editor.suggest.insertMode')) { syncConfig() if ( process.env.PLATFORM === 'node' && @@ -72,8 +79,8 @@ export const activateTsPlugin = (tsApi: { configurePlugin; onCompletionAccepted if (!activeTextEditor || !vscode.languages.match(defaultJsSupersetLangs, activeTextEditor.document)) return if (!webWaitingForConfigSync) return // webWaitingForConfigSync = false - const config = vscode.workspace.getConfiguration().get(process.env.IDS_PREFIX!) - void sendCommand(`updateConfig${JSON.stringify(config)}` as any) + const config = getResolvedConfig() + void sendCommand(`updateConfig${JSON.stringify(config)}` as any, { inputOptions: {} }) } vscode.window.onDidChangeActiveTextEditor(possiblySyncConfig) diff --git a/src/onCompletionAccepted.ts b/src/onCompletionAccepted.ts index c29c9e80..1f77d039 100644 --- a/src/onCompletionAccepted.ts +++ b/src/onCompletionAccepted.ts @@ -62,33 +62,37 @@ export default (tsApi: { onCompletionAccepted }) => { const nextChar = editor.document.getText(new vscode.Range(startPos, startPos.translate(0, 1))) if (!params || ['(', '.', '`'].includes(nextChar)) return - if (isAmbiguous && lastAcceptedAmbiguousMethodSnippetSuggestion !== suggestionName) { - lastAcceptedAmbiguousMethodSnippetSuggestion = suggestionName - return - } + if (getExtensionSetting('methodSnippetsInsertText') === 'disable') { + // handle insertion only if it wasn't handled by methodSnippetsInsertText already + if (isAmbiguous && lastAcceptedAmbiguousMethodSnippetSuggestion !== suggestionName) { + lastAcceptedAmbiguousMethodSnippetSuggestion = suggestionName + return + } - const replaceArguments = getExtensionSetting('methodSnippets.replaceArguments') - - const snippet = new vscode.SnippetString('') - snippet.appendText('(') - // todo maybe when have optional (skipped), add a way to leave trailing , with tabstop (previous behavior) - for (const [i, param] of params.entries()) { - const replacer = replaceArguments[param.replace(/\?$/, '')] - if (replacer === null) continue - if (replacer) { - useReplacer(snippet, replacer) - } else { - snippet.appendPlaceholder(param) + const replaceArguments = getExtensionSetting('methodSnippets.replaceArguments') + + const snippet = new vscode.SnippetString('') + snippet.appendText('(') + // todo maybe when have optional (skipped), add a way to leave trailing , with tabstop (previous behavior) + for (const [i, param] of params.entries()) { + const replacer = replaceArguments[param.replace(/\?$/, '')] + if (replacer === null) continue + if (replacer) { + useReplacer(snippet, replacer) + } else { + snippet.appendPlaceholder(param) + } + + if (i !== params.length - 1) snippet.appendText(', ') } - if (i !== params.length - 1) snippet.appendText(', ') + snippet.appendText(')') + void editor.insertSnippet(snippet, undefined, { + undoStopAfter: false, + undoStopBefore: false, + }) } - snippet.appendText(')') - void editor.insertSnippet(snippet, undefined, { - undoStopAfter: false, - undoStopBefore: false, - }) if (vscode.workspace.getConfiguration('editor.parameterHints').get('enabled') && params.length > 0) { void vscode.commands.executeCommand('editor.action.triggerParameterHints') } diff --git a/src/sendCommand.ts b/src/sendCommand.ts index 518bb88f..4c17a49a 100644 --- a/src/sendCommand.ts +++ b/src/sendCommand.ts @@ -1,17 +1,22 @@ import * as vscode from 'vscode' import { getActiveRegularEditor } from '@zardoy/vscode-utils' import { getExtensionSetting } from 'vscode-framework' -import { passthroughExposedApiCommands, TriggerCharacterCommand } from '../typescript/src/ipcTypes' +import { passthroughExposedApiCommands, TriggerCharacterCommand, RequestInputTypes, RequestOutputTypes } from '../typescript/src/ipcTypes' -type SendCommandData = { +type SendCommandData = { position?: vscode.Position document?: vscode.TextDocument - inputOptions?: K -} -export const sendCommand = async ( - command: TriggerCharacterCommand, - sendCommandDataArg?: SendCommandData, -): Promise => { + // eslint-disable-next-line @typescript-eslint/ban-types +} & ([Input] extends [never] ? {} : { inputOptions: Input }) + +export const sendCommand = async < + Command extends TriggerCharacterCommand, + Input = RequestInputTypes[Command & keyof RequestInputTypes], + Output = RequestOutputTypes[Command & keyof RequestOutputTypes] extends never ? any : RequestOutputTypes[Command & keyof RequestOutputTypes], +>( + command: Command, + sendCommandDataArg: SendCommandData, +): Promise => { // plugin id disabled, languageService would not understand the special trigger character if (!getExtensionSetting('enablePlugin')) { console.warn('Ignoring request because plugin is disabled') @@ -31,10 +36,10 @@ export const sendCommand = async ( } const _editor = getActiveRegularEditor()! - const { document: { uri } = _editor.document, position = _editor.selection.active, inputOptions } = sendCommandDataArg ?? {} + const { document: { uri } = _editor.document, position = _editor.selection.active } = sendCommandDataArg ?? {} - if (inputOptions) { - command = `${command}?${JSON.stringify(inputOptions)}` as any + if ('inputOptions' in sendCommandDataArg) { + command = `${command}?${JSON.stringify(sendCommandDataArg.inputOptions)}` as any } if (process.env.NODE_ENV === 'development') console.time(`request ${command}`) diff --git a/src/specialCommands.ts b/src/specialCommands.ts index f3cf33fe..74931b2c 100644 --- a/src/specialCommands.ts +++ b/src/specialCommands.ts @@ -5,7 +5,7 @@ import { showQuickPick } from '@zardoy/vscode-utils/build/quickPick' import _ from 'lodash' import { compact } from '@zardoy/utils' import { offsetPosition } from '@zardoy/vscode-utils/build/position' -import { RequestOptionsTypes, RequestResponseTypes } from '../typescript/src/ipcTypes' +import { RequestInputTypes, RequestOutputTypes } from '../typescript/src/ipcTypes' import { sendCommand } from './sendCommand' import { tsRangeToVscode, tsRangeToVscodeSelection } from './util' import { onCompletionAcceptedOverride } from './onCompletionAccepted' @@ -15,10 +15,7 @@ export default () => { const editor = getActiveRegularEditor() if (!editor) return const { selection, document } = editor - const response = await sendCommand< - RequestResponseTypes['removeFunctionArgumentsTypesInSelection'], - RequestOptionsTypes['removeFunctionArgumentsTypesInSelection'] - >('removeFunctionArgumentsTypesInSelection', { + const response = await sendCommand('removeFunctionArgumentsTypesInSelection', { document, position: selection.start, inputOptions: { @@ -37,7 +34,7 @@ export default () => { const getCurrentValueRange = async () => { const editor = getActiveRegularEditor() if (!editor) return - const result = await sendCommand('getRangeOfSpecialValue') + const result = await sendCommand('getRangeOfSpecialValue', {}) if (!result) return const range = tsRangeToVscode(editor.document, result.range) return range.with({ start: range.start.translate(0, / *{?/.exec(editor.document.lineAt(range.start).text.slice(range.start.character))![0]!.length) }) @@ -121,7 +118,7 @@ export default () => { registerExtensionCommand('pickAndInsertFunctionArguments', async () => { const editor = getActiveRegularEditor() if (!editor) return - const result = await sendCommand('pickAndInsertFunctionArguments') + const result = await sendCommand('pickAndInsertFunctionArguments', {}) if (!result) return const renderArgs = (args: Array<[name: string, type: string]>) => `${args.map(([name, type]) => (type ? `${name}: ${type}` : name)).join(', ')}` @@ -165,7 +162,7 @@ export default () => { const editor = vscode.window.activeTextEditor if (!editor) return const { document } = editor - const result = await sendCommand('filterBySyntaxKind') + const result = await sendCommand('filterBySyntaxKind', {}) if (!result) return // todo optimize if (filterWithSelection) { @@ -228,14 +225,14 @@ export default () => { document, selection: { active: position }, } = editor - await sendCommand('acceptRenameWithParams', { + await sendCommand('acceptRenameWithParams', { document, position, inputOptions: { alias, comments, strings, - } satisfies RequestOptionsTypes['acceptRenameWithParams'], + } satisfies RequestInputTypes['acceptRenameWithParams'], }) await vscode.commands.executeCommand(preview ? 'acceptRenameInputWithPreview' : 'acceptRenameInput') }) @@ -244,7 +241,7 @@ export default () => { const editor = vscode.window.activeTextEditor if (!editor) return if (!getExtensionSetting('experiments.enableInsertNameOfSuggestionFix') && editor.document.languageId !== 'vue') { - const result = await sendCommand('getLastResolvedCompletion') + const result = await sendCommand('getLastResolvedCompletion', {}) if (!result) return const position = editor.selection.active const range = result.range ? tsRangeToVscode(editor.document, result.range) : editor.document.getWordRangeAtPosition(position) @@ -283,12 +280,32 @@ export default () => { }) registerExtensionCommand('copyFullType', async () => { - const response = await sendCommand('getFullType') + const response = await sendCommand('getFullType', {}) if (!response) return const { text } = response await vscode.env.clipboard.writeText(text) }) + registerExtensionCommand('getArgumentReferencesFromCurrentParameter', async () => { + const result = await sendCommand('getArgumentReferencesFromCurrentParameter', {}) + if (!result) return + const editor = vscode.window.activeTextEditor! + const { uri } = editor.document + await vscode.commands.executeCommand( + 'editor.action.goToLocations', + uri, + editor.selection.active, + result.map(({ filename, line, character }) => new vscode.Location(vscode.Uri.file(filename), new vscode.Position(line, character))), + vscode.workspace.getConfiguration('editor').get('gotoLocation.multipleReferences') ?? 'peek', + 'No references', + ) + }) + + // registerExtensionCommand('insertImportFlatten', () => { + // // got -> default, got + // type A = ts.Type + // }) + // registerExtensionCommand('pasteCodeWithImports', async () => { // const clipboard = await vscode.env.clipboard.readText() // const lines = clipboard.split('\n') diff --git a/typescript/src/codeActions/custom/renameParameterToNameFromType.ts b/typescript/src/codeActions/custom/renameParameterToNameFromType.ts new file mode 100644 index 00000000..f40e2e90 --- /dev/null +++ b/typescript/src/codeActions/custom/renameParameterToNameFromType.ts @@ -0,0 +1,105 @@ +import { pipe, groupBy, map, compact } from 'lodash/fp' +import { CodeAction } from '../getCodeActions' +import extractType from '../../utils/extractType' + +const getTypeParamName = (parameterName: string, parameterIndex: number, functionDeclaration: ts.Node, languageService: ts.LanguageService) => { + const typeChecker = languageService.getProgram()!.getTypeChecker() + const type = extractType(typeChecker, functionDeclaration) + + const typeSignatureParams = typeChecker.getSignaturesOfType(type, ts.SignatureKind.Call)[0]?.parameters + if (!typeSignatureParams) return + const typeSignatureParam = typeSignatureParams[parameterIndex] + if (!typeSignatureParam) return + const typeParamName = typeSignatureParam.name + const isInternal = typeParamName.startsWith('__') + if (isInternal || parameterName === typeParamName) return + + return typeParamName +} + +const getEdits = (fileName: string, position: number, newText: string, languageService: ts.LanguageService): ts.FileTextChanges[] | undefined => { + const renameLocations = languageService.findRenameLocations(fileName, position, false, false, {}) + if (!renameLocations) return + + const extractFileName = ({ fileName }: ts.RenameLocation) => fileName + + return pipe( + groupBy(extractFileName), + Object.entries, + map( + ([fileName, changes]): ts.FileTextChanges => ({ + fileName, + textChanges: changes.map( + ({ textSpan }): ts.TextChange => ({ + newText, + span: textSpan, + }), + ), + }), + ), + )(renameLocations) +} + +export const renameParameterToNameFromType = { + id: 'renameParameterToNameFromType', + name: '', + kind: 'refactor.rewrite.renameParameterToNameFromType', + tryToApply(sourceFile, position, range, node, formatOptions, languageService) { + if (!node || !position) return + const functionSignature = node.parent.parent + if (!ts.isIdentifier(node) || !ts.isParameter(node.parent) || !ts.isFunctionLike(functionSignature)) return + const { parent: functionDecl, parameters: functionParameters } = functionSignature + + const parameterIndex = functionParameters.indexOf(node.parent) + const parameterName = functionParameters[parameterIndex]!.name.getText() + const typeParamName = getTypeParamName(parameterName, functionParameters.indexOf(node.parent), functionDecl, languageService) + if (!typeParamName) return + this.name = `Rename Parameter to Name from Type ('${typeParamName}')` + if (!formatOptions) return true + + const edits = compact(getEdits(sourceFile.fileName, position, typeParamName, languageService)) + return { + edits, + } + }, +} satisfies CodeAction + +export const renameAllParametersToNameFromType = { + id: 'renameAllParametersToNameFromType', + name: '', + kind: 'refactor.rewrite.renameAllParametersToNameFromType', + tryToApply(sourceFile, position, range, node, formatOptions, languageService) { + if (!node || !position) return + const functionSignature = node.parent.parent + if ( + !ts.isIdentifier(node) || + !ts.isParameter(node.parent) || + !ts.isFunctionLike(functionSignature) || + !ts.isVariableDeclaration(functionSignature.parent) + ) + return + const { parent: functionDecl, parameters: functionParameters } = functionSignature + const paramsToRename = compact( + functionParameters.map((functionParameter, index) => { + const typeParamName = getTypeParamName(functionParameter.getText(), index, functionDecl, languageService) + if (!typeParamName) return + return { param: functionParameter, typeParamName } + }), + ) + + if (paramsToRename.length < 2) return + + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + this.name = `Rename All Parameters to Name from Type '${functionDecl.type?.getText()}'` + if (!formatOptions) return true + + const edits = compact( + paramsToRename.flatMap(({ param, typeParamName }) => { + return getEdits(sourceFile.fileName, param.getStart(), typeParamName, languageService) + }), + ) + return { + edits, + } + }, +} satisfies CodeAction diff --git a/typescript/src/codeActions/getCodeActions.ts b/typescript/src/codeActions/getCodeActions.ts index 6279a97c..3938b813 100644 --- a/typescript/src/codeActions/getCodeActions.ts +++ b/typescript/src/codeActions/getCodeActions.ts @@ -6,8 +6,15 @@ import objectSwapKeysAndValues from './custom/objectSwapKeysAndValues' import changeStringReplaceToRegex from './custom/changeStringReplaceToRegex' import splitDeclarationAndInitialization from './custom/splitDeclarationAndInitialization' import addMissingProperties from './extended/addMissingProperties' +import { renameParameterToNameFromType, renameAllParametersToNameFromType } from './custom/renameParameterToNameFromType' -const codeActions: CodeAction[] = [objectSwapKeysAndValues, changeStringReplaceToRegex, splitDeclarationAndInitialization] +const codeActions: CodeAction[] = [ + objectSwapKeysAndValues, + changeStringReplaceToRegex, + splitDeclarationAndInitialization, + renameParameterToNameFromType, + renameAllParametersToNameFromType, +] const extendedCodeActions: ExtendedCodeAction[] = [addMissingProperties] type SimplifiedRefactorInfo = diff --git a/typescript/src/completionEntryDetails.ts b/typescript/src/completionEntryDetails.ts index 7c8d15fb..92e28e4c 100644 --- a/typescript/src/completionEntryDetails.ts +++ b/typescript/src/completionEntryDetails.ts @@ -1,12 +1,12 @@ import { PrevCompletionMap, PrevCompletionsAdditionalData } from './completionsAtPosition' import constructMethodSnippet from './constructMethodSnippet' -import { RequestResponseTypes } from './ipcTypes' +import { RequestOutputTypes } from './ipcTypes' import namespaceAutoImports from './namespaceAutoImports' import { GetConfig } from './types' import { wordStartAtPos } from './utils' export const lastResolvedCompletion = { - value: undefined as undefined | RequestResponseTypes['getLastResolvedCompletion'], + value: undefined as undefined | RequestOutputTypes['getLastResolvedCompletion'], } export default function completionEntryDetails( diff --git a/typescript/src/completions/arrayMethods.ts b/typescript/src/completions/arrayMethods.ts index 98ce0cb9..73c4d9fb 100644 --- a/typescript/src/completions/arrayMethods.ts +++ b/typescript/src/completions/arrayMethods.ts @@ -37,7 +37,8 @@ export default (entries: ts.CompletionEntry[], position: number, sourceFile: ts. return c('arrayMethodsSnippets.defaultItemName') }, } - const cleanSourceText = lowerCaseFirst(getItemNameFromNode(nodeBeforeDot)?.replace(/^(?:all)?(.+?)(?:List)?$/, '$1') ?? '') || defaultItemName.value + const _cleanSourceText = getItemNameFromNode(nodeBeforeDot)?.replace(/^(?:all)?(.+?)(?:List)?$/, '$1') + const cleanSourceText = _cleanSourceText ? lowerCaseFirst(_cleanSourceText) : defaultItemName.value if (!cleanSourceText) return let inferredName = pluralize.singular(cleanSourceText) // both can be undefined diff --git a/typescript/src/completions/asSuggestions.ts b/typescript/src/completions/asSuggestions.ts new file mode 100644 index 00000000..c460a192 --- /dev/null +++ b/typescript/src/completions/asSuggestions.ts @@ -0,0 +1,26 @@ +import { findChildContainingExactPosition } from '../utils' +import { sharedCompletionContext } from './sharedContext' + +export default () => { + const typeChecker = sharedCompletionContext.program.getTypeChecker() + const { position, fullText, prior } = sharedCompletionContext + if (!fullText.slice(0, position - 1).endsWith('as')) return + const node = findChildContainingExactPosition(sharedCompletionContext.sourceFile, position - 2) + if (!node || !ts.isAsExpression(node)) return + const typeAtLocation = typeChecker.getTypeAtLocation(node.expression) + const type = typeChecker.typeToString(typeAtLocation) + const widenType = typeChecker.typeToString(typeChecker.getBaseTypeOfLiteralType(typeAtLocation)) + + if (type !== widenType) { + prior.entries.push({ + kind: ts.ScriptElementKind.unknown, + name: widenType, + sortText: '!', + }) + } + prior.entries.push({ + kind: ts.ScriptElementKind.unknown, + name: type, + sortText: '!', + }) +} diff --git a/typescript/src/completions/displayImportedInfo.ts b/typescript/src/completions/displayImportedInfo.ts index b370067b..777e0a36 100644 --- a/typescript/src/completions/displayImportedInfo.ts +++ b/typescript/src/completions/displayImportedInfo.ts @@ -1,3 +1,4 @@ +import getImportPath from '../utils/getImportPath' import { sharedCompletionContext } from './sharedContext' export default (entries: ts.CompletionEntry[]) => { @@ -9,24 +10,13 @@ export default (entries: ts.CompletionEntry[]) => { for (const entry of entries) { const { symbol } = entry if (!symbol) continue - const [node] = symbol.declarations ?? [] - if (!node) continue - let importDeclaration: ts.ImportDeclaration | undefined - if (ts.isImportSpecifier(node) && ts.isNamedImports(node.parent) && ts.isImportDeclaration(node.parent.parent.parent)) { - importDeclaration = node.parent.parent.parent - } else if (ts.isImportClause(node) && ts.isImportDeclaration(node.parent)) { - importDeclaration = node.parent - } else if (ts.isNamespaceImport(node) && ts.isImportClause(node.parent) && ts.isImportDeclaration(node.parent.parent)) { - // todo-low(builtin) maybe reformat text - importDeclaration = node.parent.parent - } - if (importDeclaration) { - prevCompletionsMap[entry.name] ??= {} - let importPath = importDeclaration.moduleSpecifier.getText() - const symbolsLimit = 40 - if (importPath.length > symbolsLimit) importPath = `${importPath.slice(0, symbolsLimit / 2)}...${importPath.slice(-symbolsLimit / 2)}` - const detailPrepend = displayImportedInfo === 'short-format' ? `(from ${importPath}) ` : `Imported from ${importPath}\n\n` - prevCompletionsMap[entry.name]!.detailPrepend = detailPrepend - } + let { quotedPath: importPath } = getImportPath(symbol) ?? {} + if (!importPath) continue + + prevCompletionsMap[entry.name] ??= {} + const symbolsLimit = 40 + if (importPath.length > symbolsLimit) importPath = `${importPath.slice(0, symbolsLimit / 2)}...${importPath.slice(-symbolsLimit / 2)}` + const detailPrepend = displayImportedInfo === 'short-format' ? `(from ${importPath}) ` : `Imported from ${importPath}\n\n` + prevCompletionsMap[entry.name]!.detailPrepend = detailPrepend } } diff --git a/typescript/src/completions/functionCompletions.ts b/typescript/src/completions/functionCompletions.ts index 0c06eef4..7c1ab441 100644 --- a/typescript/src/completions/functionCompletions.ts +++ b/typescript/src/completions/functionCompletions.ts @@ -1,13 +1,15 @@ import { oneOf } from '@zardoy/utils' import constructMethodSnippet from '../constructMethodSnippet' -import { insertTextAfterEntry } from '../utils' +import { insertTextAfterEntry, wordRangeAtPos } from '../utils' import { sharedCompletionContext } from './sharedContext' export default (entries: ts.CompletionEntry[]) => { - const { languageService, c, sourceFile, position } = sharedCompletionContext + const { languageService, c, sourceFile, position, prior } = sharedCompletionContext const methodSnippetInsertTextMode = c('methodSnippetsInsertText') - const nextChar = sourceFile.getFullText().slice(position, position + 1) + const fullText = sourceFile.getFullText() + const nextChar = fullText.slice(position, position + 1) + const prevChar = fullText.slice(position - 1, position) const isMethodSnippetInsertTextModeEnabled = methodSnippetInsertTextMode !== 'disable' const enableResolvingInsertText = !['(', '.', '`'].includes(nextChar) && c('enableMethodSnippets') && isMethodSnippetInsertTextModeEnabled @@ -47,13 +49,21 @@ export default (entries: ts.CompletionEntry[]) => { if (!methodSnippet || resolveData.isAmbiguous) return const originalText = entry.insertText ?? entry.name const insertTextSnippetAdd = `(${methodSnippet.map((x, i) => `$\{${i + 1}:${x}}`).join(', ')})` + // https://github.com/zardoy/typescript-vscode-plugins/issues/161 + const beforeDotWorkaround = prior.isMemberCompletion && prevChar === '.' return { ...entry, - insertText: insertTextAfterEntry(originalText, insertTextSnippetAdd), + insertText: (beforeDotWorkaround ? '.' : '') + insertTextAfterEntry(originalText, insertTextSnippetAdd), labelDetails: { detail: `(${methodSnippet.join(', ')})`, description: ts.displayPartsToString(entry.sourceDisplay), }, + replacementSpan: beforeDotWorkaround + ? { + start: position - 1, + length: (c('editorSuggestInsertModeReplace') ? wordRangeAtPos(fullText, position).length : 0) + 1, + } + : undefined, kind: ts.ScriptElementKind.functionElement, isSnippet: true, } diff --git a/typescript/src/completions/sharedContext.ts b/typescript/src/completions/sharedContext.ts index ca9d66b9..8ac0a46d 100644 --- a/typescript/src/completions/sharedContext.ts +++ b/typescript/src/completions/sharedContext.ts @@ -14,5 +14,7 @@ export const sharedCompletionContext = {} as unknown as Readonly<{ c: GetConfig formatOptions: ts.FormatCodeSettings preferences: ts.UserPreferences + fullText: string + typeChecker: ts.TypeChecker // languageServiceHost: ts.LanguageServiceHost }> diff --git a/typescript/src/completionsAtPosition.ts b/typescript/src/completionsAtPosition.ts index da69554e..6787248f 100644 --- a/typescript/src/completionsAtPosition.ts +++ b/typescript/src/completionsAtPosition.ts @@ -31,6 +31,7 @@ import stringTemplateTypeCompletions from './completions/stringTemplateType' import localityBonus from './completions/localityBonus' import functionCompletions from './completions/functionCompletions' import staticHintSuggestions from './completions/staticHintSuggestions' +import asSuggestions from './completions/asSuggestions' export type PrevCompletionMap = Record< string, @@ -139,6 +140,8 @@ export const getCompletionsAtPosition = ( formatOptions: formatOptions || {}, preferences: options || {}, prior: prior!, + fullText: sourceFile.getFullText(), + typeChecker: program.getTypeChecker(), } satisfies typeof sharedCompletionContext) if (node && !hasSuggestions && ensurePrior() && prior) { @@ -296,9 +299,11 @@ export const getCompletionsAtPosition = ( if (c('improveJsxCompletions') && leftNode) prior.entries = improveJsxCompletions(prior.entries, leftNode, position, sourceFile, c('jsxCompletionsMap')) prior.entries = localityBonus(prior.entries) ?? prior.entries + asSuggestions() prior.entries.push(...(staticHintSuggestions() ?? [])) const processedEntries = new Set() + for (const rule of c('replaceSuggestions')) { if (rule.filter?.fileNamePattern) { // todo replace with something better diff --git a/typescript/src/constructMethodSnippet.ts b/typescript/src/constructMethodSnippet.ts index 8b2b781c..a1a6cdca 100644 --- a/typescript/src/constructMethodSnippet.ts +++ b/typescript/src/constructMethodSnippet.ts @@ -2,6 +2,7 @@ import { compact, oneOf } from '@zardoy/utils' import { isTypeNode } from './completions/keywordsSpace' import { GetConfig } from './types' import { findChildContainingExactPosition } from './utils' +import extractType from './utils/extractType' // todo-low-ee inspect any last arg infer export default ( @@ -17,11 +18,9 @@ export default ( ) => { let containerNode = findChildContainingExactPosition(sourceFile, position) if (!containerNode || isTypeNode(containerNode)) return - const checker = languageService.getProgram()!.getTypeChecker()! - let type = symbol ? checker.getTypeOfSymbol(symbol) : checker.getTypeAtLocation(containerNode) - // give another chance - if (symbol && type['intrinsicName'] === 'error') type = checker.getTypeOfSymbolAtLocation(symbol, containerNode) + + const type = extractType(checker, containerNode, symbol) if (ts.isIdentifier(containerNode)) containerNode = containerNode.parent if (ts.isPropertyAccessExpression(containerNode)) containerNode = containerNode.parent diff --git a/typescript/src/decorateEditsForFileRename.ts b/typescript/src/decorateEditsForFileRename.ts new file mode 100644 index 00000000..7aef0f7a --- /dev/null +++ b/typescript/src/decorateEditsForFileRename.ts @@ -0,0 +1,49 @@ +import { camelCase } from 'change-case' +import _ from 'lodash' +import { GetConfig } from './types' +import { approveCast, findChildContainingExactPosition } from './utils' + +export default (proxy: ts.LanguageService, languageService: ts.LanguageService, c: GetConfig) => { + proxy.getEditsForFileRename = (oldFilePath, newFilePath, formatOptions, preferences) => { + let edits = languageService.getEditsForFileRename(oldFilePath, newFilePath, formatOptions, preferences) + if (c('renameImportNameOfFileRename')) { + const predictedNameFromPath = (p: string) => camelCase(p.split(/[/\\]/g).pop()!.replace(/\..+/, '')) + const oldPredictedName = predictedNameFromPath(oldFilePath) + const newPredictedName = predictedNameFromPath(newFilePath) + for (const edit of edits) { + const possiblyAddRename = (identifier: ts.Identifier | undefined) => { + if (identifier?.text !== oldPredictedName) return + const sourceFile = languageService.getProgram()!.getSourceFile(edit.fileName)! + const newRenameEdits = proxy.findRenameLocations(edit.fileName, identifier.pos, false, false, preferences ?? {}) ?? [] + if (!newRenameEdits) return + // maybe cancel symbol rename on collision instead? + const newInsertName = tsFull.getUniqueName(newPredictedName, sourceFile as any) + const addEdits = Object.entries(_.groupBy(newRenameEdits, ({ fileName }) => fileName)).map( + ([fileName, changes]): ts.FileTextChanges => ({ + fileName, + textChanges: changes.map( + ({ prefixText = '', suffixText = '', textSpan }): ts.TextChange => ({ + newText: prefixText + newInsertName + suffixText, + span: textSpan, + }), + ), + }), + ) + edits = [...edits, ...addEdits] + } + for (const textChange of edit.textChanges) { + const node = findChildContainingExactPosition(languageService.getProgram()!.getSourceFile(edit.fileName)!, textChange.span.start) + if (!node) continue + if (node && ts.isStringLiteral(node) && ts.isImportDeclaration(node.parent) && node.parent.importClause) { + const { importClause } = node.parent + possiblyAddRename(importClause?.name) + if (approveCast(importClause.namedBindings, ts.isNamespaceImport)) { + possiblyAddRename(importClause.namedBindings.name) + } + } + } + } + } + return edits + } +} diff --git a/typescript/src/decorateFindRenameLocations.ts b/typescript/src/decorateFindRenameLocations.ts index 10071969..6b6444b0 100644 --- a/typescript/src/decorateFindRenameLocations.ts +++ b/typescript/src/decorateFindRenameLocations.ts @@ -1,9 +1,9 @@ -import { RequestOptionsTypes } from './ipcTypes' +import { RequestInputTypes } from './ipcTypes' import { GetConfig } from './types' import { findChildContainingExactPosition } from './utils' export const overrideRenameRequest = { - value: undefined as undefined | RequestOptionsTypes['acceptRenameWithParams'], + value: undefined as undefined | RequestInputTypes['acceptRenameWithParams'], } export default (proxy: ts.LanguageService, languageService: ts.LanguageService, c: GetConfig) => { diff --git a/typescript/src/decorateProxy.ts b/typescript/src/decorateProxy.ts index 2b32f647..2d930bd5 100644 --- a/typescript/src/decorateProxy.ts +++ b/typescript/src/decorateProxy.ts @@ -1,8 +1,6 @@ import lodashGet from 'lodash.get' -import { camelCase } from 'change-case' -import _ from 'lodash' import { getCompletionsAtPosition, PrevCompletionMap, PrevCompletionsAdditionalData } from './completionsAtPosition' -import { RequestOptionsTypes, TriggerCharacterCommand } from './ipcTypes' +import { RequestInputTypes, TriggerCharacterCommand } from './ipcTypes' import { getNavTreeItems } from './getPatchedNavTree' import decorateCodeActions from './codeActions/decorateProxy' import decorateSemanticDiagnostics from './semanticDiagnostics' @@ -17,8 +15,9 @@ import decorateWorkspaceSymbolSearch from './workspaceSymbolSearch' import decorateFormatFeatures from './decorateFormatFeatures' import libDomPatching from './libDomPatching' import decorateSignatureHelp from './decorateSignatureHelp' -import { approveCast, findChildContainingExactPosition } from './utils' import decorateFindRenameLocations from './decorateFindRenameLocations' +import decorateQuickInfoAtPosition from './decorateQuickInfoAtPosition' +import decorateEditsForFileRename from './decorateEditsForFileRename' /** @internal */ export const thisPluginMarker = '__essentialPluginsMarker__' @@ -46,49 +45,6 @@ export const decorateLanguageService = ( let prevCompletionsMap: PrevCompletionMap let prevCompletionsAdditionalData: PrevCompletionsAdditionalData - proxy.getEditsForFileRename = (oldFilePath, newFilePath, formatOptions, preferences) => { - let edits = languageService.getEditsForFileRename(oldFilePath, newFilePath, formatOptions, preferences) - if (c('renameImportNameOfFileRename')) { - const predictedNameFromPath = (p: string) => camelCase(p.split(/[/\\]/g).pop()!.replace(/\..+/, '')) - const oldPredictedName = predictedNameFromPath(oldFilePath) - const newPredictedName = predictedNameFromPath(newFilePath) - for (const edit of edits) { - const possiblyAddRename = (identifier: ts.Identifier | undefined) => { - if (identifier?.text !== oldPredictedName) return - const sourceFile = languageService.getProgram()!.getSourceFile(edit.fileName)! - const newRenameEdits = proxy.findRenameLocations(edit.fileName, identifier.pos, false, false) ?? [] - if (!newRenameEdits) return - // maybe cancel symbol rename on collision instead? - const newInsertName = tsFull.getUniqueName(newPredictedName, sourceFile as any) - const addEdits = Object.entries(_.groupBy(newRenameEdits, ({ fileName }) => fileName)).map( - ([fileName, changes]): ts.FileTextChanges => ({ - fileName, - textChanges: changes.map( - ({ prefixText = '', suffixText = '', textSpan }): ts.TextChange => ({ - newText: prefixText + newInsertName + suffixText, - span: textSpan, - }), - ), - }), - ) - edits = [...edits, ...addEdits] - } - for (const textChange of edit.textChanges) { - const node = findChildContainingExactPosition(languageService.getProgram()!.getSourceFile(edit.fileName)!, textChange.span.start) - if (!node) continue - if (node && ts.isStringLiteral(node) && ts.isImportDeclaration(node.parent) && node.parent.importClause) { - const { importClause } = node.parent - possiblyAddRename(importClause?.name) - if (approveCast(importClause.namedBindings, ts.isNamespaceImport)) { - possiblyAddRename(importClause.namedBindings.name) - } - } - } - } - } - return edits - } - proxy.getCompletionsAtPosition = (fileName, position, options, formatOptions) => { if (options?.triggerCharacter && typeof options.triggerCharacter !== 'string') { return languageService.getCompletionsAtPosition(fileName, position, options) @@ -133,6 +89,7 @@ export const decorateLanguageService = ( proxy.getCompletionEntryDetails = (...inputArgs) => completionEntryDetails(inputArgs, languageService, prevCompletionsMap, c, prevCompletionsAdditionalData) + decorateEditsForFileRename(proxy, languageService, c) decorateCodeActions(proxy, languageService, languageServiceHost, c) decorateCodeFixes(proxy, languageService, languageServiceHost, c) decorateSemanticDiagnostics(proxy, languageService, languageServiceHost, c) @@ -143,6 +100,7 @@ export const decorateLanguageService = ( decorateFormatFeatures(proxy, languageService, languageServiceHost, c) decorateSignatureHelp(proxy, languageService, languageServiceHost, c) decorateFindRenameLocations(proxy, languageService, c) + decorateQuickInfoAtPosition(proxy, languageService, languageServiceHost, c) libDomPatching(languageServiceHost, c) diff --git a/typescript/src/decorateQuickInfoAtPosition.ts b/typescript/src/decorateQuickInfoAtPosition.ts new file mode 100644 index 00000000..f890538a --- /dev/null +++ b/typescript/src/decorateQuickInfoAtPosition.ts @@ -0,0 +1,39 @@ +import { GetConfig } from './types' +import { findChildContainingExactPosition } from './utils' +import getImportPath from './utils/getImportPath' + +export default (proxy: ts.LanguageService, languageService: ts.LanguageService, languageServiceHost: ts.LanguageServiceHost, c: GetConfig) => { + proxy.getQuickInfoAtPosition = (...args) => { + const [fileName, position] = args + const prior = languageService.getQuickInfoAtPosition(...args) + if (!prior) return + const program = languageService.getProgram()! + const sourceFile = program.getSourceFile(fileName)! + + if (c('suggestions.displayImportedInfo') !== 'disable') { + const node = findChildContainingExactPosition(sourceFile, position) + const possiblyImportKeywords = prior.displayParts?.at(-3) + if (possiblyImportKeywords?.text === 'import' && node) { + const symbolAtLocation = program.getTypeChecker().getSymbolAtLocation(node) + if (symbolAtLocation) { + const result = getImportPath(symbolAtLocation) + if (result) { + const { quotedPath: importPath, importKind } = result + + prior.displayParts!.at(-3)!.text = + { + [ts.SyntaxKind.NamespaceImport]: 'import * as', + [ts.SyntaxKind.NamedImports]: 'import {', + }[importKind] ?? possiblyImportKeywords.text + + prior.displayParts = [ + ...(prior.displayParts || []), + { kind: 'text', text: `${importKind === ts.SyntaxKind.NamedImports ? ' }' : ''} from ${importPath}` }, + ] + } + } + } + } + return prior + } +} diff --git a/typescript/src/ipcTypes.ts b/typescript/src/ipcTypes.ts index 94dfc02d..c9b713ca 100644 --- a/typescript/src/ipcTypes.ts +++ b/typescript/src/ipcTypes.ts @@ -15,6 +15,7 @@ export const triggerCharacterCommands = [ 'acceptRenameWithParams', 'getExtendedCodeActionEdits', 'getLastResolvedCompletion', + 'getArgumentReferencesFromCurrentParameter', ] as const export type TriggerCharacterCommand = (typeof triggerCharacterCommands)[number] @@ -41,11 +42,38 @@ export type IpcExtendedCodeAction = { codes?: number[] } +// INPUT +export type RequestInputTypes = { + removeFunctionArgumentsTypesInSelection: { + endSelection: number + } + getTwoStepCodeActions: { + range: [number, number] + } + twoStepCodeActionSecondStep: { + range: [number, number] + data: { + name: 'turnArrayIntoObject' + selectedKeyName?: string + } + } + + acceptRenameWithParams: { + comments: boolean + strings: boolean + alias: boolean + } + getExtendedCodeActionEdits: { + range: [number, number] + applyCodeActionTitle: string + } +} + // OUTPUT /** * @keysSuggestions TriggerCharacterCommand */ -export type RequestResponseTypes = { +export type RequestOutputTypes = { removeFunctionArgumentsTypesInSelection: { ranges: TsRange[] } @@ -84,33 +112,8 @@ export type RequestResponseTypes = { getFullType: { text: string } -} - -// INPUT -export type RequestOptionsTypes = { - removeFunctionArgumentsTypesInSelection: { - endSelection: number - } - getTwoStepCodeActions: { - range: [number, number] - } - twoStepCodeActionSecondStep: { - range: [number, number] - data: { - name: 'turnArrayIntoObject' - selectedKeyName?: string - } - } - - acceptRenameWithParams: { - comments: boolean - strings: boolean - alias: boolean - } - getExtendedCodeActionEdits: { - range: [number, number] - applyCodeActionTitle: string - } + getArgumentReferencesFromCurrentParameter: Array<{ line: number; character: number; filename: string }> + 'emmet-completions': EmmetResult } // export type EmmetResult = { diff --git a/typescript/src/references.ts b/typescript/src/references.ts index d07b1a69..ef56036b 100644 --- a/typescript/src/references.ts +++ b/typescript/src/references.ts @@ -13,29 +13,29 @@ export default (proxy: ts.LanguageService, languageService: ts.LanguageService, } if (c('removeImportsFromReferences')) { const program = languageService.getProgram()! - const importsCountPerFileName: Record< + const refCountPerFileName: Record< string, { - all: number - cur: number + total: number + current: number } > = {} const allReferences = prior.flatMap(({ references }) => references) for (const { fileName } of allReferences) { - importsCountPerFileName[fileName] ??= { - all: 0, - cur: 0, + refCountPerFileName[fileName] ??= { + total: 0, + current: 0, } - importsCountPerFileName[fileName]!.all++ + refCountPerFileName[fileName]!.total++ } prior = prior.map(({ references, ...other }) => { return { ...other, references: references.filter(({ fileName, textSpan }) => { - const importsCount = importsCountPerFileName[fileName]! + const refsCount = refCountPerFileName[fileName]! // doesn't make sense to handle case where it gets imports twice - if (importsCount.all <= 1 || importsCount.cur !== 0) return true - importsCount.cur++ + if (refsCount.total <= 1 || refsCount.current !== 0) return true + refsCount.current++ const sourceFile = program.getSourceFile(fileName) if (!sourceFile) return true const end = textSpan.start + textSpan.length diff --git a/typescript/src/specialCommands/handle.ts b/typescript/src/specialCommands/handle.ts index ba3a9d83..80e3d0e9 100644 --- a/typescript/src/specialCommands/handle.ts +++ b/typescript/src/specialCommands/handle.ts @@ -1,6 +1,6 @@ import { compact } from '@zardoy/utils' import { getExtendedCodeActions } from '../codeActions/getCodeActions' -import { NodeAtPositionResponse, RequestOptionsTypes, RequestResponseTypes, TriggerCharacterCommand, triggerCharacterCommands } from '../ipcTypes' +import { NodeAtPositionResponse, RequestInputTypes, RequestOutputTypes, TriggerCharacterCommand, triggerCharacterCommands } from '../ipcTypes' import { GetConfig } from '../types' import { findChildContainingExactPosition, findChildContainingPosition, getNodePath } from '../utils' import { lastResolvedCompletion } from '../completionEntryDetails' @@ -35,7 +35,7 @@ export default ( } // todo rename from getTwoStepCodeActions to additionalCodeActions if (specialCommand === 'getTwoStepCodeActions') { - changeType(specialCommandArg) + changeType(specialCommandArg) const node = findChildContainingPosition(ts, sourceFile, position) const posEnd = { pos: specialCommandArg.range[0], end: specialCommandArg.range[1] } @@ -46,7 +46,7 @@ export default ( } } if (specialCommand === 'getExtendedCodeActionEdits') { - changeType(specialCommandArg) + changeType(specialCommandArg) const { range, applyCodeActionTitle } = specialCommandArg const posEnd = { pos: range[0], end: range[1] } return getExtendedCodeActions( @@ -55,13 +55,13 @@ export default ( languageService, formatOptions, applyCodeActionTitle, - ) satisfies RequestResponseTypes['getExtendedCodeActionEdits'] + ) satisfies RequestOutputTypes['getExtendedCodeActionEdits'] } if (specialCommand === 'twoStepCodeActionSecondStep') { - changeType(specialCommandArg) + changeType(specialCommandArg) const node = findChildContainingPosition(ts, sourceFile, position) const posEnd = { pos: specialCommandArg.range[0], end: specialCommandArg.range[1] } - let data: RequestResponseTypes['twoStepCodeActionSecondStep'] | undefined + let data: RequestOutputTypes['twoStepCodeActionSecondStep'] | undefined switch (specialCommandArg.data.name) { case 'turnArrayIntoObject': { data = { @@ -108,7 +108,7 @@ export default ( return edits } if (specialCommand === 'removeFunctionArgumentsTypesInSelection') { - changeType(specialCommandArg) + changeType(specialCommandArg) const node = findChildContainingPosition(ts, sourceFile, position) if (!node) return @@ -177,12 +177,12 @@ export default ( if (targetNode) { return { range: Array.isArray(targetNode) ? targetNode : [targetNode.pos, targetNode.end], - } satisfies RequestResponseTypes['getRangeOfSpecialValue'] + } satisfies RequestOutputTypes['getRangeOfSpecialValue'] } return } if (specialCommand === 'acceptRenameWithParams') { - changeType(specialCommandArg) + changeType(specialCommandArg) overrideRenameRequest.value = specialCommandArg return undefined } @@ -212,10 +212,10 @@ export default ( ), ] }), - } satisfies RequestResponseTypes['pickAndInsertFunctionArguments'] + } satisfies RequestOutputTypes['pickAndInsertFunctionArguments'] } if (specialCommand === 'filterBySyntaxKind') { - const collectedNodes: RequestResponseTypes['filterBySyntaxKind']['nodesByKind'] = {} + const collectedNodes: RequestOutputTypes['filterBySyntaxKind']['nodesByKind'] = {} collectedNodes.comment ??= [] const collectNodes = (node: ts.Node) => { const kind = ts.SyntaxKind[node.kind]! @@ -232,7 +232,7 @@ export default ( sourceFile.forEachChild(collectNodes) return { nodesByKind: collectedNodes, - } satisfies RequestResponseTypes['filterBySyntaxKind'] + } satisfies RequestOutputTypes['filterBySyntaxKind'] } if (specialCommand === 'getLastResolvedCompletion') { return lastResolvedCompletion.value @@ -246,6 +246,37 @@ export default ( text: checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.NoTypeReduction), } } + if (specialCommand === 'getArgumentReferencesFromCurrentParameter') { + const node = findChildContainingExactPosition(sourceFile, position) + if (!node || !ts.isIdentifier(node) || !ts.isParameter(node.parent) || !ts.isFunctionLike(node.parent.parent)) return + let functionDecl = node.parent.parent as ts.Node + const functionParameters = node.parent.parent.parameters + if (ts.isVariableDeclaration(functionDecl.parent)) { + functionDecl = functionDecl.parent + } + const parameterIndex = functionParameters.indexOf(node.parent) + const references = languageService.findReferences(fileName, functionDecl.pos + functionDecl.getLeadingTriviaWidth(sourceFile)) + if (!references) return + + return compact( + references.flatMap(({ references }) => { + return references.map(reference => { + const sourceFile = languageService.getProgram()!.getSourceFile(reference.fileName)! + const position = reference.textSpan.start + + const node = findChildContainingExactPosition(sourceFile, position) + if (!node || !ts.isIdentifier(node) || !ts.isCallExpression(node.parent)) return + + const arg = node.parent.arguments[parameterIndex] + if (!arg) return + return { + filename: reference.fileName, + ...sourceFile.getLineAndCharacterOfPosition(arg.pos + arg.getLeadingTriviaWidth(sourceFile)), + } + }) + }), + ) + } return null } diff --git a/typescript/src/specialCommands/objectIntoArrayConverters.ts b/typescript/src/specialCommands/objectIntoArrayConverters.ts index 7bdfbf6d..741cdd4b 100644 --- a/typescript/src/specialCommands/objectIntoArrayConverters.ts +++ b/typescript/src/specialCommands/objectIntoArrayConverters.ts @@ -1,4 +1,4 @@ -import { RequestResponseTypes } from '../ipcTypes' +import { RequestOutputTypes } from '../ipcTypes' import { approveCast, getIndentFromPos } from '../utils' const nodeToSpan = (node: ts.Node): ts.TextSpan => { @@ -6,7 +6,7 @@ const nodeToSpan = (node: ts.Node): ts.TextSpan => { return { start, length: node.end - start } } -type FirstStepData = RequestResponseTypes['getTwoStepCodeActions']['turnArrayIntoObject'] +type FirstStepData = RequestOutputTypes['getTwoStepCodeActions']['turnArrayIntoObject'] // primarily for working with static data export default (range: { pos: number; end: number }, node: ts.Node | undefined, selectedKeyName: T): any => { diff --git a/typescript/src/types.ts b/typescript/src/types.ts index e8a7efc5..9c9c8370 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -3,8 +3,9 @@ import { ConditionalPick } from 'type-fest' //@ts-expect-error import type { Configuration } from '../../src/configurationType' // eslint-disable-next-line @typescript-eslint/no-redeclare -export type Configuration = Configuration -export type GetConfig = (key: T) => Configuration[T] +export type Configuration = Configuration & { editorSuggestInsertModeReplace: boolean } +type LocalConfig = Configuration & { editorSuggestInsertModeReplace: boolean } +export type GetConfig = (key: T) => LocalConfig[T] export type LanguageServiceMethodWithConfig any>> = ( c: GetConfig, ...args: Parameters diff --git a/typescript/src/utils.ts b/typescript/src/utils.ts index 0de245e7..6a4ccbec 100644 --- a/typescript/src/utils.ts +++ b/typescript/src/utils.ts @@ -246,7 +246,7 @@ export const getCancellationToken = (languageServiceHost: ts.LanguageServiceHost return cancellationToken } -const wordRangeAtPos = (text: string, position: number) => { +export const wordRangeAtPos = (text: string, position: number) => { const isGood = (pos: number) => { return /[-\w\d]/i.test(text.at(pos) ?? '') } diff --git a/typescript/src/utils/extractType.ts b/typescript/src/utils/extractType.ts new file mode 100644 index 00000000..ef1048dd --- /dev/null +++ b/typescript/src/utils/extractType.ts @@ -0,0 +1,6 @@ +export default (typeChecker: ts.TypeChecker, node: ts.Node, symbol?: ts.Symbol) => { + const type = symbol ? typeChecker.getTypeOfSymbol(symbol) : typeChecker.getTypeAtLocation(node) + // give another chance + if (symbol && type['intrinsicName'] === 'error') return typeChecker.getTypeOfSymbolAtLocation(symbol, node) + return type +} diff --git a/typescript/src/utils/getImportPath.ts b/typescript/src/utils/getImportPath.ts new file mode 100644 index 00000000..a405dd5b --- /dev/null +++ b/typescript/src/utils/getImportPath.ts @@ -0,0 +1,24 @@ +export default (symbol: ts.Symbol) => { + const [node] = symbol.declarations ?? [] + if (!node) return + + if (!node) return undefined + let importDeclaration: ts.ImportDeclaration | undefined + let importKind!: ts.SyntaxKind + if (ts.isImportSpecifier(node) && ts.isNamedImports(node.parent) && ts.isImportDeclaration(node.parent.parent.parent)) { + importDeclaration = node.parent.parent.parent + importKind = ts.SyntaxKind.NamedImports + } else if (ts.isImportClause(node) && ts.isImportDeclaration(node.parent)) { + importDeclaration = node.parent + } else if (ts.isNamespaceImport(node) && ts.isImportClause(node.parent) && ts.isImportDeclaration(node.parent.parent)) { + // todo-low(builtin) maybe reformat text + importDeclaration = node.parent.parent + importKind = ts.SyntaxKind.NamespaceImport + } + + if (!importDeclaration) return undefined + return { + quotedPath: importDeclaration.moduleSpecifier.getText(), + importKind, + } +} diff --git a/typescript/src/volarConfig.ts b/typescript/src/volarConfig.ts index d3f5b126..94a141f4 100644 --- a/typescript/src/volarConfig.ts +++ b/typescript/src/volarConfig.ts @@ -67,6 +67,7 @@ const plugin: (...args: Parameters) => const getResolvedUserConfig = async () => { const regularConfig = await configurationHost.getConfiguration!('tsEssentialPlugins') + const editorSuggestInsertModeReplace = (await configurationHost.getConfiguration!('editor.suggest.insertMode')) === 'replace' const _vueSpecificConfig = (await configurationHost.getConfiguration!('[vue]')) || {} const vueSpecificConfig = Object.fromEntries( @@ -76,7 +77,7 @@ const plugin: (...args: Parameters) => ), ), ) - const config: Configuration = mergeAndPatchConfig(regularConfig, vueSpecificConfig) + const config: Configuration = { ...mergeAndPatchConfig(regularConfig, vueSpecificConfig), editorSuggestInsertModeReplace } return config } diff --git a/typescript/test/completions.spec.ts b/typescript/test/completions.spec.ts index 4135d0cc..3a52a62a 100644 --- a/typescript/test/completions.spec.ts +++ b/typescript/test/completions.spec.ts @@ -365,15 +365,22 @@ test('Emmet completion', () => { }) test('Array Method Snippets', () => { - const positions = newFileContents(/*ts*/ ` + const { completion } = fourslashLikeTester(/*ts*/ ` const users = [] - users./*|*/ - ;users.filter(Boolean).flatMap/*|*/ + users./*0*/ + ;users.filter(Boolean).flatMap/*1*/ + ;[]./*2*/ `) - for (const [i, pos] of positions.entries()) { - const { entries } = getCompletionsAtPosition(pos) ?? {} - expect(entries?.find(({ name }) => name === 'flatMap')?.insertText, i.toString()).toBe('flatMap((${2:user}) => $3)') - } + completion([0, 1], { + includes: { + insertTexts: ['flatMap((${2:user}) => $3)'], + }, + }) + completion(2, { + includes: { + insertTexts: ['flatMap((${2:item}) => $3)'], + }, + }) }) test('String template type completions', () => { diff --git a/typescript/test/testing.ts b/typescript/test/testing.ts index 1112ed11..fe61fd98 100644 --- a/typescript/test/testing.ts +++ b/typescript/test/testing.ts @@ -109,12 +109,20 @@ export const fourslashLikeTester = (contents: string, fileName = entrypoint) => } } if (includes) { - const { names, all } = includes + const { names, all, insertTexts } = includes if (names) { for (const name of names) { expect(result?.entryNames, message).toContain(name) } } + if (insertTexts) { + for (const insertText of insertTexts) { + expect( + result.entries.map(entry => entry.insertText), + message, + ).toContain(insertText) + } + } if (all) { for (const entry of result.entries.filter(e => names?.includes(e.name))) { expect(entry, entry.name + message).toContain(all)