From bdc5878c3c2ad172d0e0e6e29da726b2fdd6e2fd Mon Sep 17 00:00:00 2001 From: LW Date: Thu, 27 Jul 2023 00:25:39 -0700 Subject: [PATCH] feat: snippet completions for functions (#116) --- .luacheckrc | 3 + README.md | 2 + lua/typescript-tools/config.lua | 69 +++++++++++++ lua/typescript-tools/protocol/constants.lua | 1 + .../text_document/completion/init.lua | 11 ++- .../text_document/completion/resolve.lua | 96 ++++++++++++++++++- .../protocol/text_document/did_open.lua | 59 +----------- .../protocol/text_document/inlay_hint.lua | 3 +- lua/typescript-tools/protocol/utils.lua | 12 +++ tests/requests_spec.lua | 58 +++++++++-- 10 files changed, 245 insertions(+), 69 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index 2805f458..d78d31a7 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -2,6 +2,9 @@ ignore = { "631", -- max_line_length } +exclude_files = { + ".tests", +} globals = { "vim", "P" } read_globals = { "describe", diff --git a/README.md b/README.md index 70141828..df07c650 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,8 @@ require("typescript-tools").setup { -- described below tsserver_format_options = {}, tsserver_file_preferences = {}, + -- mirror of VSCode's `typescript.suggest.completeFunctionCalls` + complete_function_calls = false, }, } ``` diff --git a/lua/typescript-tools/config.lua b/lua/typescript-tools/config.lua index 5e2e4622..0ff56ef1 100644 --- a/lua/typescript-tools/config.lua +++ b/lua/typescript-tools/config.lua @@ -8,10 +8,61 @@ ---@field tsserver_format_options table|fun(filetype: string): table ---@field tsserver_file_preferences table|fun(filetype: string): table ---@field tsserver_max_memory number|"auto" +---@field complete_function_calls boolean ---@field expose_as_code_action ("fix_all"| "add_missing_imports"| "remove_unused")[] local M = {} local __store = {} +-- INFO: this two defaults are same as in vscode +local default_format_options = { + insertSpaceAfterCommaDelimiter = true, + insertSpaceAfterConstructor = false, + insertSpaceAfterSemicolonInForStatements = true, + insertSpaceBeforeAndAfterBinaryOperators = true, + insertSpaceAfterKeywordsInControlFlowStatements = true, + insertSpaceAfterFunctionKeywordForAnonymousFunctions = true, + insertSpaceBeforeFunctionParenthesis = false, + insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis = false, + insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets = false, + insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces = true, + insertSpaceAfterOpeningAndBeforeClosingEmptyBraces = true, + insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces = false, + insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces = false, + insertSpaceAfterTypeAssertion = false, + placeOpenBraceOnNewLineForFunctions = false, + placeOpenBraceOnNewLineForControlBlocks = false, + semicolons = "ignore", + indentSwitchCase = true, +} + +local default_preferences = { + quotePreference = "auto", + importModuleSpecifierEnding = "auto", + jsxAttributeCompletionStyle = "auto", + allowTextChangesInNewFiles = true, + providePrefixAndSuffixTextForRename = true, + allowRenameOfImportPath = true, + includeAutomaticOptionalChainCompletions = true, + provideRefactorNotApplicableReason = true, + generateReturnInDocTemplate = true, + includeCompletionsForImportStatements = true, + includeCompletionsWithSnippetText = true, + includeCompletionsWithClassMemberSnippets = true, + includeCompletionsWithObjectLiteralMethodSnippets = true, + useLabelDetailsInCompletionEntries = true, + allowIncompleteCompletions = true, + displayPartsForJSDoc = true, + disableLineTextInReferences = true, + includeInlayParameterNameHints = "none", + includeInlayParameterNameHintsWhenArgumentMatchesName = false, + includeInlayFunctionParameterTypeHints = false, + includeInlayVariableTypeHints = false, + includeInlayVariableTypeHintsWhenTypeMatchesName = false, + includeInlayPropertyDeclarationTypeHints = false, + includeInlayFunctionLikeReturnTypeHints = false, + includeInlayEnumMemberValueHints = false, +} + ---@enum tsserver_log_level M.tsserver_log_level = { normal = "normal", @@ -56,6 +107,7 @@ function M.load_settings(settings) { "number", "string" }, true, }, + ["settings.complete_function_calls"] = { settings.complete_function_calls, "boolean", true }, ["settings.expose_as_code_action"] = { settings.expose_as_code_action, "table", @@ -93,6 +145,10 @@ function M.load_settings(settings) __store.tsserver_max_memory = "auto" end + if not settings.complete_function_calls then + __store.complete_function_calls = false + end + if not settings.expose_as_code_action then __store.expose_as_code_action = {} end @@ -104,4 +160,17 @@ setmetatable(M, { end, }) +---@param filetype vim.opt.filetype +---@return table +function M.get_tsserver_file_preferences(filetype) + local preferences = __store.tsserver_file_preferences + return vim.tbl_extend( + "force", + default_preferences, + type(preferences) == "function" and preferences(filetype) or preferences + ) +end + +M.default_format_options = default_format_options + return M diff --git a/lua/typescript-tools/protocol/constants.lua b/lua/typescript-tools/protocol/constants.lua index 9945af8b..1b72b9b4 100644 --- a/lua/typescript-tools/protocol/constants.lua +++ b/lua/typescript-tools/protocol/constants.lua @@ -270,6 +270,7 @@ return { Imports = "imports", Region = "region", }, + ---@enum InsertTextFormat InsertTextFormat = { PlainText = 1, Snippet = 2, diff --git a/lua/typescript-tools/protocol/text_document/completion/init.lua b/lua/typescript-tools/protocol/text_document/completion/init.lua index 3e566708..e2a68bdb 100644 --- a/lua/typescript-tools/protocol/text_document/completion/init.lua +++ b/lua/typescript-tools/protocol/text_document/completion/init.lua @@ -25,6 +25,8 @@ function M.handler(request, response, params) local text_document = params.textDocument local context = params.context or {} local trigger_character = context.triggerCharacter + local requested_bufnr = vim.uri_to_bufnr(text_document.uri) + local filetype = vim.bo[requested_bufnr].filetype -- tsserver protocol reference: -- https//github.com/microsoft/TypeScript/blob/8b482b513d87c6fcda8ece18b99f8a01cff5c605/lib/protocol.d.ts#L1631 @@ -65,14 +67,19 @@ function M.handler(request, response, params) sortText = "\u{ffff}" .. item.sortText end + local should_create_function_snippet = utils.should_create_function_snippet(kind, filetype) + local should_create_snippet = item.isSnippet or should_create_function_snippet + local label = is_optional and (item.name .. "?") or item.name + label = should_create_function_snippet and (label .. "(...)") or label + return { - label = is_optional and (item.name .. "?") or item.name, + label = label, labelDetails = item.labelDetails, insertText = insertText, filterText = insertText, commitCharacters = item_kind_utils.calculate_commit_characters(kind), kind = kind, - insertTextFormat = item.isSnippet and c.InsertTextFormat.Snippet + insertTextFormat = should_create_snippet and c.InsertTextFormat.Snippet or c.InsertTextFormat.PlainText, sortText = sortText, textEdit = calculate_text_edit(item.replacementSpan, insertText), diff --git a/lua/typescript-tools/protocol/text_document/completion/resolve.lua b/lua/typescript-tools/protocol/text_document/completion/resolve.lua index a1f81ddf..5540df79 100644 --- a/lua/typescript-tools/protocol/text_document/completion/resolve.lua +++ b/lua/typescript-tools/protocol/text_document/completion/resolve.lua @@ -26,6 +26,89 @@ local function make_text_edits(code_actions) return text_edits end +---@alias DisplayPart { kind: string, text: string } + +---@param display_parts DisplayPart[] +---@return { has_optional_parameters: boolean, parts: DisplayPart[] } +---@see https://github.com/typescript-language-server/typescript-language-server/blob/983a6923114c39d638e0c7d419ae16e8bca8985c/src/completion.ts#L355-L371 +local function get_parameter_list_parts(display_parts) + local parts = {} + local is_in_method = false + local has_optional_parameters = false + local paren_count = 0 + local brace_count = 0 + + for i, part in ipairs(display_parts) do + if + part.kind == "methodName" + or part.kind == "functionName" + or part.kind == "text" + or part.kind == "propertyName" + then + if paren_count == 0 and brace_count == 0 then + is_in_method = true + end + elseif part.kind == "parameterName" then + if paren_count == 1 and brace_count == 0 and is_in_method then + local next = display_parts[i + 1] + local name_is_followed_by_optional_indicator = next and next.text == "?" + local name_is_this = part.text == "this" + if not name_is_followed_by_optional_indicator and not name_is_this then + table.insert(parts, part) + end + has_optional_parameters = has_optional_parameters or name_is_followed_by_optional_indicator + end + elseif part.kind == "punctuation" then + if part.text == "(" then + paren_count = paren_count + 1 + elseif part.text == ")" then + paren_count = paren_count - 1 + if paren_count <= 0 and is_in_method then + break + end + elseif part.text == "..." and paren_count == 1 then + has_optional_parameters = true + break + elseif part.text == "{" then + brace_count = brace_count + 1 + elseif part.text == "}" then + brace_count = brace_count - 1 + end + end + end + return { has_optional_parameters = has_optional_parameters, parts = parts } +end + +---@alias PartialCompletionItem +---| { insertText: string, insertTextFormat: InsertTextFormat, textEdit: { newText: string }, label: string } + +---@param item PartialCompletionItem +---@param display_parts DisplayPart[] +---@return nil +---@see https://github.com/typescript-language-server/typescript-language-server/blob/983a6923114c39d638e0c7d419ae16e8bca8985c/src/completion.ts#L355-L371 +local function create_snippet(item, display_parts) + local parameter_list_parts = get_parameter_list_parts(display_parts) + local has_optional_parameters = parameter_list_parts.has_optional_parameters + local parts = parameter_list_parts.parts + local snippet = + string.format("%s(", item.insertText or (item.textEdit and item.textEdit.newText) or item.label) + for i, part in ipairs(parts) do + snippet = snippet .. string.format("${%d:%s}", i, part.text:gsub("([$}\\])", "\\%1")) + if i ~= #parts then + snippet = snippet .. ", " + end + end + if has_optional_parameters then + snippet = snippet .. string.format("$%d", #parts + 1) + end + snippet = snippet .. ")$0" + item.insertText = snippet + item.insertTextFormat = c.InsertTextFormat.Snippet + if item.textEdit then + item.textEdit.newText = snippet + end +end + ---@param params table ---@return table local function completion_resolve_request(params) @@ -50,6 +133,9 @@ end ---@type TsserverProtocolHandler function M.handler(request, response, params) + local requested_bufnr = vim.uri_to_bufnr(vim.uri_from_fname(params.data.file)) + local filetype = vim.bo[requested_bufnr].filetype + -- tsserver protocol reference: -- https://github.com/microsoft/TypeScript/blob/549e61d0af1ba885be29d69f341e7d3a00686071/lib/protocol.d.ts#L1661 request(completion_resolve_request(params)) @@ -74,7 +160,7 @@ function M.handler(request, response, params) detail = "Auto import from " .. utils.tsserver_docs_to_plain_text(source) .. "\n" .. detail end - response(vim.tbl_extend("force", params, { + local item = vim.tbl_extend("force", params, { detail = detail, documentation = { kind = c.MarkupKind.Markdown, @@ -83,7 +169,13 @@ function M.handler(request, response, params) additionalTextEdits = make_text_edits(details.codeActions), -- INFO: there is also `command` prop but I don't know there is usecase for that here, -- or neovim even handle that for now i skip this - })) + }) + + if utils.should_create_function_snippet(item.kind, filetype) then + create_snippet(item, details.displayParts) + end + + response(item) else response(nil) end diff --git a/lua/typescript-tools/protocol/text_document/did_open.lua b/lua/typescript-tools/protocol/text_document/did_open.lua index b203c2de..a374d3a3 100644 --- a/lua/typescript-tools/protocol/text_document/did_open.lua +++ b/lua/typescript-tools/protocol/text_document/did_open.lua @@ -4,56 +4,6 @@ local plugin_config = require "typescript-tools.config" local M = {} --- INFO: this two defaults are same as in vscode -local default_format_options = { - insertSpaceAfterCommaDelimiter = true, - insertSpaceAfterConstructor = false, - insertSpaceAfterSemicolonInForStatements = true, - insertSpaceBeforeAndAfterBinaryOperators = true, - insertSpaceAfterKeywordsInControlFlowStatements = true, - insertSpaceAfterFunctionKeywordForAnonymousFunctions = true, - insertSpaceBeforeFunctionParenthesis = false, - insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis = false, - insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets = false, - insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces = true, - insertSpaceAfterOpeningAndBeforeClosingEmptyBraces = true, - insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces = false, - insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces = false, - insertSpaceAfterTypeAssertion = false, - placeOpenBraceOnNewLineForFunctions = false, - placeOpenBraceOnNewLineForControlBlocks = false, - semicolons = "ignore", - indentSwitchCase = true, -} - -local default_preferences = { - quotePreference = "auto", - importModuleSpecifierEnding = "auto", - jsxAttributeCompletionStyle = "auto", - allowTextChangesInNewFiles = true, - providePrefixAndSuffixTextForRename = true, - allowRenameOfImportPath = true, - includeAutomaticOptionalChainCompletions = true, - provideRefactorNotApplicableReason = true, - generateReturnInDocTemplate = true, - includeCompletionsForImportStatements = true, - includeCompletionsWithSnippetText = true, - includeCompletionsWithClassMemberSnippets = true, - includeCompletionsWithObjectLiteralMethodSnippets = true, - useLabelDetailsInCompletionEntries = true, - allowIncompleteCompletions = true, - displayPartsForJSDoc = true, - disableLineTextInReferences = true, - includeInlayParameterNameHints = "none", - includeInlayParameterNameHintsWhenArgumentMatchesName = false, - includeInlayFunctionParameterTypeHints = false, - includeInlayVariableTypeHints = false, - includeInlayVariableTypeHintsWhenTypeMatchesName = false, - includeInlayPropertyDeclarationTypeHints = false, - includeInlayFunctionLikeReturnTypeHints = false, - includeInlayEnumMemberValueHints = false, -} - ---@type table<"mac" | "unix" | "dos", string> local eol_chars = { mac = "\r", @@ -78,7 +28,6 @@ local function configure(params) local convert_tabs_to_spaces = bo.expandtab or true local new_line_character = get_eol_chars(bo) - local preferences = plugin_config.tsserver_file_preferences local format_options = plugin_config.tsserver_format_options return { @@ -93,14 +42,10 @@ local function configure(params) convertTabsToSpaces = convert_tabs_to_spaces, newLineCharacter = new_line_character, }, - default_format_options, + plugin_config.default_format_options, type(format_options) == "function" and format_options(bo.filetype) or format_options ), - preferences = vim.tbl_extend( - "force", - default_preferences, - type(preferences) == "function" and preferences(bo.filetype) or preferences - ), + preferences = plugin_config.get_tsserver_file_preferences(bo.filetype), }, } end diff --git a/lua/typescript-tools/protocol/text_document/inlay_hint.lua b/lua/typescript-tools/protocol/text_document/inlay_hint.lua index 87985561..8c570621 100644 --- a/lua/typescript-tools/protocol/text_document/inlay_hint.lua +++ b/lua/typescript-tools/protocol/text_document/inlay_hint.lua @@ -13,8 +13,7 @@ local inlay_hint_kind_map = { ---@param filetype string ---@return boolean local function are_inlay_hints_enabled(filetype) - local preferences = plugin_config.tsserver_file_preferences - preferences = type(preferences) == "function" and preferences(filetype) or preferences + local preferences = plugin_config.get_tsserver_file_preferences(filetype) if not preferences then return false diff --git a/lua/typescript-tools/protocol/utils.lua b/lua/typescript-tools/protocol/utils.lua index 18f01fb6..d74c6358 100644 --- a/lua/typescript-tools/protocol/utils.lua +++ b/lua/typescript-tools/protocol/utils.lua @@ -1,4 +1,5 @@ local c = require "typescript-tools.protocol.constants" +local plugin_config = require "typescript-tools.config" local M = {} @@ -222,4 +223,15 @@ function M.cancelled_response(data) } end +---@param kind CompletionItemKind +---@param filetype vim.opt.filetype +---@return boolean +---@see https://github.com/typescript-language-server/typescript-language-server/blob/983a6923114c39d638e0c7d419ae16e8bca8985c/src/completion.ts#L355-L371 +function M.should_create_function_snippet(kind, filetype) + local preferences = plugin_config.get_tsserver_file_preferences(filetype) + return preferences.includeCompletionsWithSnippetText + and (kind == c.CompletionItemKind.Function or kind == c.CompletionItemKind.Method) + and plugin_config.complete_function_calls +end + return M diff --git a/tests/requests_spec.lua b/tests/requests_spec.lua index 7e21a3ef..e641a810 100644 --- a/tests/requests_spec.lua +++ b/tests/requests_spec.lua @@ -1,6 +1,7 @@ local utils = require "tests.utils" local lsp_assert = require "tests.lsp_asserts" local mocks = require "tests.mocks" +local plugin_config = require "typescript-tools.config" local c = require "typescript-tools.protocol.constants" local methods = c.LspMethods local custom_methods = c.CustomMethods @@ -134,10 +135,11 @@ describe("Lsp request", function() utils.open_file "src/completion.ts" utils.wait_for_lsp_initialization() - local ret = vim.lsp.buf_request_sync(0, methods.Completion, { + local req = { textDocument = utils.get_text_document(), position = utils.make_position(0, 8), - }) + } + local ret = vim.lsp.buf_request_sync(0, methods.Completion, req) local result = lsp_assert.response(ret) @@ -147,19 +149,46 @@ describe("Lsp request", function() assert.is.True(#items >= 20) local completions = vim.tbl_map(function(it) + if it.kind == c.CompletionItemKind.Method or it.kind == c.CompletionItemKind.Function then + assert.are.same(it.insertTextFormat, c.InsertTextFormat.PlainText) + end return it.label end, items) table.sort(completions) assert.are.same(completions[1], "assert") assert.are.same(completions[#completions], "warn") + + -- same test as above but with function snippets enabled + local prev_config = plugin_config.complete_function_calls + plugin_config.complete_function_calls = true + + ret = vim.lsp.buf_request_sync(0, methods.Completion, req) + result = lsp_assert.response(ret) + assert.is.table(result.items) + + items = result.items + assert.is.True(#items >= 20) + + completions = vim.tbl_map(function(it) + if it.kind == c.CompletionItemKind.Method or it.kind == c.CompletionItemKind.Function then + assert.are.same(it.insertTextFormat, c.InsertTextFormat.Snippet) + end + return it.label + end, items) + table.sort(completions) + + assert.are.same(completions[1], "assert(...)") + assert.are.same(completions[#completions], "warn(...)") + + plugin_config.complete_function_calls = prev_config end) it("should return correct response for " .. methods.CompletionResolve, function() utils.open_file "src/completion.ts" utils.wait_for_lsp_initialization() - local ret = vim.lsp.buf_request_sync(0, methods.CompletionResolve, { + local req = { commitCharacters = { "(" }, data = { character = 8, @@ -169,15 +198,32 @@ describe("Lsp request", function() }, filterText = "warn", insertText = "warn", - insertTextFormat = 1, - kind = 2, + insertTextFormat = c.InsertTextFormat.PlainText, + kind = c.CompletionItemKind.Function, label = "warn", sortText = "11", - }) + } + local ret = vim.lsp.buf_request_sync(0, methods.CompletionResolve, req) local result = lsp_assert.response(ret) assert.is.table(result) + assert.are.same(result.insertText, "warn") + assert.are.same(result.detail, "(method) Console.warn(...data: any[]): void") + + -- same test as above but with function snippets enabled + local prev_config = plugin_config.complete_function_calls + plugin_config.complete_function_calls = true + + req.label = "warn(...)" + req.insertTextFormat = c.InsertTextFormat.Snippet + ret = vim.lsp.buf_request_sync(0, methods.CompletionResolve, req) + result = lsp_assert.response(ret) + + assert.is.table(result) + assert.are.same(result.insertText, "warn($1)$0") assert.are.same(result.detail, "(method) Console.warn(...data: any[]): void") + + plugin_config.complete_function_calls = prev_config end) it("should return correct response for " .. methods.SignatureHelp, function()