aboutsummaryrefslogtreecommitdiffstats
path: root/lua/nvim-lsp-installer/server.lua
blob: 66d9655f104119be76bec2da20fe80f627ac8863 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
local dispatcher = require "nvim-lsp-installer.dispatcher"
local a = require "nvim-lsp-installer.core.async"
local InstallContext = require "nvim-lsp-installer.core.installer.context"
local fs = require "nvim-lsp-installer.fs"
local log = require "nvim-lsp-installer.log"
local platform = require "nvim-lsp-installer.platform"
local settings = require "nvim-lsp-installer.settings"
local installers = require "nvim-lsp-installer.installers"
local installer = require "nvim-lsp-installer.core.installer"
local servers = require "nvim-lsp-installer.servers"
local status_win = require "nvim-lsp-installer.ui.status-win"
local path = require "nvim-lsp-installer.path"
local receipt = require "nvim-lsp-installer.core.receipt"
local Optional = require "nvim-lsp-installer.core.optional"

local M = {}

-- old, but also somewhat convenient, API
M.get_server_root_path = servers.get_server_install_path

---@alias ServerDeprecation {message:string, replace_with:string|nil}
---@alias ServerOpts {name:string, root_dir:string, homepage:string|nil, deprecated:ServerDeprecation, installer:ServerInstallerFunction|ServerInstallerFunction[], default_options:table, languages: string[]}

---@class Server
---@field public  name string @The server name. This is the same as lspconfig's server names.
---@field public  root_dir string @The directory where the server should be installed in.
---@field public  homepage string|nil @The homepage where users can find more information. This is shown to users in the UI.
---@field public  deprecated ServerDeprecation|nil @The existence (not nil) of this field indicates this server is depracted.
---@field public  languages string[]
---@field private _installer ServerInstallerFunction
---@field private _async boolean
---@field private _on_ready_handlers fun(server: Server)[]
---@field private _default_options table @The server's default options. This is used in @see Server#setup.
M.Server = {}
M.Server.__index = M.Server

---@param opts ServerOpts
---@return Server
function M.Server:new(opts)
    return setmetatable({
        name = opts.name,
        root_dir = opts.root_dir,
        homepage = opts.homepage,
        deprecated = opts.deprecated,
        _async = opts.async or false,
        languages = opts.languages or {},
        _on_ready_handlers = {},
        _installer = opts.installer,
        _default_options = opts.default_options,
    }, M.Server)
end

---Sets up the language server via lspconfig. This function has the same signature as the setup function in nvim-lspconfig.
---@param opts table @The lspconfig server configuration.
function M.Server:setup_lsp(opts)
    -- We require the lspconfig server here in order to do it as late as possible.
    -- The reason for this is because once a lspconfig server has been imported, it's
    -- automatically registered with lspconfig and causes it to show up in :LspInfo and whatnot.
    local lsp_server = require("lspconfig")[self.name]
    if lsp_server then
        lsp_server.setup(vim.tbl_deep_extend("force", self._default_options, opts or {}))
    else
        error(
            (
                "Unable to setup server %q: Could not find lspconfig server entry. Make sure you are running a recent version of lspconfig."
            ):format(self.name)
        )
    end
end

---Sets up the language server and attaches all open buffers.
---@param opts table @The lspconfig server configuration.
function M.Server:setup(opts)
    assert(
        not settings.uses_new_setup,
        "Please set up servers directly via lspconfig instead of going through nvim-lsp-installer (this method is now deprecated)! Refer to :h nvim-lsp-installer-quickstart for more information."
    )
    self:setup_lsp(opts)
    if not (opts.autostart == false) then
        self:attach_buffers()
    end
end

---Attaches this server to all current open buffers with a 'filetype' that matches the server's configured filetypes.
function M.Server:attach_buffers()
    log.trace("Attaching server to buffers", self.name)
    local lsp_server = require("lspconfig")[self.name]
    for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
        if lsp_server.filetypes then
            log.fmt_trace("Attaching server=%s to bufnr=%s using filetypes wrapper", self.name, bufnr)
            lsp_server.manager.try_add_wrapper(bufnr)
        else
            log.fmt_trace("Attaching server=%s to bufnr=%s", self.name, bufnr)
            lsp_server.manager.try_add(bufnr)
        end
    end
    log.trace("Successfully attached server to buffers", self.name)
end

---Registers a handler (callback) to be executed when the server is ready to be setup.
---@param handler fun(server: Server)
function M.Server:on_ready(handler)
    table.insert(self._on_ready_handlers, handler)
    if self:is_installed() then
        handler(self)
    end
end

---@return table @A deep copy of this server's default options. Note that these default options are nvim-lsp-installer specific, and does not include any default options provided by lspconfig.
function M.Server:get_default_options()
    return vim.deepcopy(self._default_options)
end

---@return string[] @The list of supported filetypes.
function M.Server:get_supported_filetypes()
    local metadata = require "nvim-lsp-installer._generated.metadata"

    if metadata[self.name] then
        return metadata[self.name].filetypes
    end

    return {}
end

function M.Server:get_settings_schema()
    local ok, schema = pcall(require, ("nvim-lsp-installer._generated.schemas.%s"):format(self.name))
    return (ok and schema) or nil
end

---@return boolean
function M.Server:is_installed()
    return servers.is_server_installed(self.name)
end

---Queues the server to be asynchronously installed.
---@param version string|nil @The version of the server to install. If nil, the latest version will be installed.
function M.Server:install(version)
    status_win().install_server(self, version)
end

function M.Server:get_tmp_install_dir()
    return path.concat { settings.current.install_root_dir, ("%s.tmp"):format(self.name) }
end

---@param context ServerInstallContext
function M.Server:_setup_install_context(context)
    context.install_dir = self:get_tmp_install_dir()
    fs.rm_mkdirp(context.install_dir)

    if not fs.dir_exists(settings.current.install_root_dir) then
        fs.mkdirp(settings.current.install_root_dir)
    end
end

---Removes any existing installation of the server, and moves/promotes the provided install_dir directory to its place.
---@param install_dir string @The installation directory to move to the server's root directory.
function M.Server:promote_install_dir(install_dir)
    if self.root_dir == install_dir then
        log.fmt_debug("Install dir %s is already promoted for %s", install_dir, self.name)
        return true
    end
    log.fmt_debug("Promoting installation directory %s for %s", install_dir, self.name)
    -- 1. Remove final installation directory, if it exists
    if fs.dir_exists(self.root_dir) then
        local rmrf_ok, rmrf_err = pcall(fs.rmrf, self.root_dir)
        if not rmrf_ok then
            log.fmt_error("Failed to remove final installation directory. path=%s error=%s", self.root_dir, rmrf_err)
            return false
        end
    end

    -- 2. Move the temporary install dir to the final installation directory
    if platform.is_unix then
        -- Some Unix systems will raise an error when renaming a directory to a destination that does not already exist.
        fs.mkdir(self.root_dir)
    end
    local rename_ok, rename_err = pcall(fs.rename, install_dir, self.root_dir)
    if not rename_ok then
        --- 2a. We failed to rename the temporary dir to the final installation dir
        log.fmt_error("Failed to rename. path=%s new_path=%s error=%s", install_dir, self.root_dir, rename_err)
        return false
    end
    log.fmt_debug("Successfully promoted install_dir=%s for %s", install_dir, self.name)
    return true
end

function M.Server:_get_receipt_path()
    return path.concat { self.root_dir, "nvim-lsp-installer-receipt.json" }
end

---@param receipt_builder InstallReceiptBuilder
function M.Server:_write_receipt(receipt_builder)
    if receipt_builder.is_marked_invalid then
        log.fmt_debug("Skipping writing receipt for %s because it is marked as invalid.", self.name)
        return
    end
    receipt_builder:with_name(self.name):with_schema_version("1.0a"):with_completion_time(vim.loop.gettimeofday())

    local receipt_success, install_receipt = pcall(receipt_builder.build, receipt_builder)
    if receipt_success then
        pcall(fs.write_file, self:_get_receipt_path(), vim.json.encode(install_receipt))
    else
        log.fmt_error("Failed to build receipt for server=%s. Error=%s", self.name, install_receipt)
    end
end

---@return InstallReceipt|nil
function M.Server:get_receipt()
    local receipt_path = self:_get_receipt_path()
    if fs.file_exists(receipt_path) then
        local receipt_json = vim.json.decode(fs.read_file(receipt_path))
        return receipt.InstallReceipt.from_json(receipt_json)
    end
    return nil
end

---@param context ServerInstallContext
---@param callback ServerInstallCallback
function M.Server:install_attached(context, callback)
    if self._async then
        a.run(function()
            local install_context = InstallContext.new {
                name = self.name,
                boundary_path = settings.current.install_root_dir,
                stdio_sink = context.stdio_sink,
                destination_dir = self.root_dir,
                requested_version = Optional.of_nilable(context.requested_server_version),
            }
            installer.execute(install_context, self._installer):get_or_throw()
            a.scheduler()
            dispatcher.dispatch_server_ready(self)
            for _, on_ready_handler in ipairs(self._on_ready_handlers) do
                on_ready_handler(self)
            end
        end, callback)
    else
        --- Deprecated
        a.run(
            function()
                context.receipt = receipt.InstallReceiptBuilder.new()
                context.receipt:with_start_time(vim.loop.gettimeofday())

                a.scheduler()
                self:_setup_install_context(context)
                local async_installer = a.promisify(function(server, context, callback)
                    local normalized_installer = type(self._installer) == "function" and self._installer
                        or installers.pipe(self._installer)
                    -- args are shifted
                    return normalized_installer(server, callback, context)
                end)
                assert(async_installer(self, context), "Installation failed.")

                a.scheduler()
                if not self:promote_install_dir(context.install_dir) then
                    error(("Failed to promote the temporary installation directory %q."):format(context.install_dir))
                end

                self:_write_receipt(context.receipt)

                -- Dispatch the server is ready
                vim.schedule(function()
                    dispatcher.dispatch_server_ready(self)
                    for _, on_ready_handler in ipairs(self._on_ready_handlers) do
                        on_ready_handler(self)
                    end
                end)
            end,
            vim.schedule_wrap(function(ok, result)
                if not ok then
                    pcall(fs.rmrf, context.install_dir)
                    log.fmt_error("Server installation failed, server_name=%s, error=%s", self.name, result)
                    context.stdio_sink.stderr(tostring(result) .. "\n")
                end
                -- The tmp dir should in most cases have been "promoted" and already renamed to its final destination,
                -- but we make sure to delete it should the installer modify the installation working directory during
                -- installation.
                pcall(fs.rmrf, self:get_tmp_install_dir())
                callback(ok)
            end)
        )
    end
end

function M.Server:uninstall()
    log.debug("Uninstalling server", self.name)
    if fs.dir_exists(self.root_dir) then
        fs.rmrf(self.root_dir)
    end
end

return M