diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 0000000..8ea47f0 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,33 @@ +--- +name: Format + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + format: + name: Stylua + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: date +%W > weekly + + - name: Restore cache + id: cache + uses: actions/cache@v2 + with: + path: | + ~/.cargo/bin + key: ${{ runner.os }}-cargo-${{ hashFiles('weekly') }} + + - name: Install + if: steps.cache.outputs.cache-hit != 'true' + run: cargo install stylua + + - name: Format + run: stylua --check lua/ --config-path=.stylua.toml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..1911410 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,25 @@ +--- +name: Lint + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + lint: + name: Luacheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup + run: | + sudo apt-get update + sudo apt-get install luarocks -y + sudo luarocks install luacheck + + - name: Lint + run: luacheck lua/ --globals vim diff --git a/.github/workflows/m.yml b/.github/workflows/m.yml new file mode 100644 index 0000000..1911410 --- /dev/null +++ b/.github/workflows/m.yml @@ -0,0 +1,25 @@ +--- +name: Lint + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + lint: + name: Luacheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup + run: | + sudo apt-get update + sudo apt-get install luarocks -y + sudo luarocks install luacheck + + - name: Lint + run: luacheck lua/ --globals vim diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..241cdda --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Test + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + nvim-versions: ['stable', 'nightly'] + os: [ubuntu-latest, macos-latest] + # TODO:add windows tests back in when we implement path normalisation + # os: [ubuntu-latest, windows-latest, macos-latest] + fail-fast: false + name: Plenary Tests + steps: + - name: checkout + uses: actions/checkout@v4 + + - uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: ${{ matrix.nvim-versions }} + + - name: run tests + run: make test diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..58bcb20 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,2 @@ +max_comment_line_length = false +globals = { "vim", "describe", "it", "before_each", "after_each", "assert", "async" } diff --git a/.luarc.json b/.luarc.json index e69de29..3826102 100644 --- a/.luarc.json +++ b/.luarc.json @@ -0,0 +1,15 @@ +{ + "runtime": { + "version": "LuaJIT", + "pathStrict": true + }, + "type": { + "checkTableShape": true + }, + "diagnostics.globals": [ + "describe", + "it", + "before_each", + "after_each" + ] +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bbf2ada --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +TESTS_INIT=tests/minimal.lua +TESTS_DIR=tests/ + +.PHONY: test + +test: + @nvim \ + --headless \ + --noplugin \ + -u ${TESTS_INIT} \ + -c "PlenaryBustedDirectory ${TESTS_DIR} { minimal_init = '${TESTS_INIT}' }" \ diff --git a/README.md b/README.md index e658cf7..621bfdf 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,35 @@ to bring this support to Neovim. The LSP can be cloned and compiled from source from the `dotnet/razor` repo. +## Dependencies + +You must install the following plugins: + +* [nvim-neotest/nvim-nio](https://github.com/nvim-neotest/nvim-nio) +* [seblj/roslyn.nvim](https://github.com/seblj/roslyn.nvim) + +The `html-lsp` is also required to provide completions. You can install it with +Mason and configure it via `nvim-lspconfig`. + ## Integration You can pass a configuration table to the `setup` function. The configuration options are: -- `on_attach`: A function that is called when the LSP client attaches to a buffer. -- `capabilities`: A table that defines the capabilities of the LSP client. -- `path`: The path to the rzls executable. +* `on_attach`: A function that is called when the LSP client attaches to a buffer. +* `capabilities`: A table that defines the capabilities of the LSP client. +* `path`: The path to the rzls executable. + +You also must configure the [`roslyn.nvim`](https://github.com/seblj/roslyn.nvim) plugin +to communicate with the razor LSP. To do so, you must pass the handlers defined in the +`rzls.roslyn_handlers` module: + +```lua +require('roslyn').setup { + config = { + handlers = require('rzls.roslyn_handlers') + } +} +``` ## Under Construction @@ -32,4 +54,4 @@ Issue. Your input is valuable in making this plugin more robust and efficient. ## Helping Out If you want to help out, then please see the discussion here, and leave a -comment with your details in this [discussion](https://github.com/tris203/rzls.nvim/discussions/1). +comment matches your details in this [discussion](https://github.com/tris203/rzls.nvim/discussions/1). diff --git a/lua/rzls/documentstore.lua b/lua/rzls/documentstore.lua new file mode 100644 index 0000000..caa1614 --- /dev/null +++ b/lua/rzls/documentstore.lua @@ -0,0 +1,169 @@ +local razor = require("rzls.razor") +local utils = require("rzls.utils") +local VirtualDocument = require("rzls.virtual_document") + +---@class rzls.ProjectedDocuments +---@field virtual_html rzls.ProjectedDocument +---@field virtual_csharp rzls.ProjectedDocument + +---@class rzls.ProjectedDocument +---@field buf number +---@field hostDocumentVersion number +---@field content string + +local M = {} + +local virtual_suffixes = { + html = "__virtual.html", + csharp = "__virtual.cs", +} + +---@type table> +local virtual_documents = {} + +local roslyn_ready = false + +---@param name string +local function buffer_with_name(name) + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + local buf_name = vim.api.nvim_buf_get_name(buf) + if buf_name == name then + return buf + end + end + + return -1 +end + +local function get_or_create_buffer_for_filepath(filepath, filetype) + local buf = buffer_with_name(filepath) + if buf == -1 then + vim.print(filepath) + buf = vim.api.nvim_create_buf(true, false) + vim.api.nvim_buf_set_name(buf, filepath) + vim.api.nvim_set_option_value("ft", filetype, { buf = buf }) + end + + return buf +end + +---Registers virtual buffers for the given file path +---@param current_file string +function M.register_vbufs_by_path(current_file) + -- open virtual files + -- + virtual_documents[current_file] = virtual_documents[current_file] or {} + + if vim.tbl_isempty(virtual_documents[current_file]) then + virtual_documents[current_file] = VirtualDocument:new(current_file, razor.language_kinds.razor) + end + + if virtual_documents[current_file][razor.language_kinds.csharp] == nil then + local buf = get_or_create_buffer_for_filepath(current_file .. virtual_suffixes.csharp, "cs") + + virtual_documents[current_file][razor.language_kinds.csharp] = + VirtualDocument:new(buf, razor.language_kinds.csharp) + end + + if virtual_documents[current_file][razor.language_kinds.html] == nil then + local buf = get_or_create_buffer_for_filepath(current_file .. virtual_suffixes.html, "html") + + virtual_documents[current_file][razor.language_kinds.html] = VirtualDocument:new(buf, razor.language_kinds.html) + end +end + +---@param result VBufUpdate +---@param language_kind razor.LanguageKind +function M.update_vbuf(result, language_kind) + M.register_vbufs_by_path(result.hostDocumentFilePath) + local virtual_document = virtual_documents[result.hostDocumentFilePath][language_kind] + + virtual_document:update_content(result) + + local buf_eol = utils.buffer_eol(virtual_document.buf) + local lines = vim.fn.split(virtual_document.content, buf_eol, true) + vim.api.nvim_buf_set_lines(virtual_document.buf, 0, -1, false, lines) +end + +---Creates virtual buffers for the given source buffer +---@param source_buf integer +function M.register_vbufs(source_buf) + local currentFile = vim.api.nvim_buf_get_name(source_buf) + return M.register_vbufs_by_path(currentFile) +end + +---Converts a RPC return URI to a file path +---@param uri string +---@return string +local function uri_to_path(uri) + local path = uri:gsub("file://", "") + return path +end + +---@param uri string +---@param _version integer +---@param type razor.LanguageKind +---@return rzls.VirtualDocument +function M.get_virtual_document(uri, _version, type) + return virtual_documents[uri_to_path(uri)][type] +end + +local document_generation_initialized = false + +---@param client vim.lsp.Client +function M.initialize(client, root_dir) + if not roslyn_ready or document_generation_initialized then + return + end + + document_generation_initialized = true + + local razor_files = vim.fs.find(function(name) + return name:match(".*%.razor$") + end, { + type = "file", + limit = math.huge, + path = root_dir, + }) + + local opened_documents = vim.tbl_keys(virtual_documents) + for _, razor_file in ipairs(razor_files) do + if not vim.tbl_contains(opened_documents, razor_file) then + local razor_buf = get_or_create_buffer_for_filepath(razor_file, "razor") + vim.api.nvim_buf_call(razor_buf, vim.cmd.edit) + end + end + + local pipe_name = utils.uuid() + + ---@type rzls.ProjectedDocument + local virtual_document = vim.tbl_values(virtual_documents)[1] + + local function initialize_roslyn() + local initialized = vim.lsp.buf_notify(virtual_document.buf, "razor/initialize", { + pipeName = pipe_name, + }) + + -- Roslyn might not have been initialized yet. Repeat every seconds until + -- we can send the notification + if not initialized then + local timer = vim.uv.new_timer() + timer:start(1000, 0, initialize_roslyn) + return + end + + client.notify("razor/namedPipeConnect", { + pipeName = pipe_name, + }) + end + + initialize_roslyn() +end + +---@param client vim.lsp.Client +function M.rosyln_is_ready(client, root_dir) + roslyn_ready = true + M.initialize(client, root_dir) +end + +return M diff --git a/lua/rzls/documentstore/init.lua b/lua/rzls/documentstore/init.lua deleted file mode 100644 index 7f63544..0000000 --- a/lua/rzls/documentstore/init.lua +++ /dev/null @@ -1,146 +0,0 @@ ----@class rzls.ProjectedDocuments ----@field virtualHTML rzls.ProjectedDocument ----@field virtualCSharp rzls.ProjectedDocument - ----@class rzls.ProjectedDocument ----@field buf number ----@field hostDocumentVersion number - -local M = {} - -local projectedHTMLSuffix = "__virtual.html" -local projectedCSharpSuffix = "__virtual.cs" - ----@type table -local projectedDocuments = {} - ----Updates the C# buffer with the new content ----@param result VBufUpdate -function M.update_csharp_vbuf(result) - local wasEmpty = result.previousWasEmpty - local targetBuf = projectedDocuments[result.hostDocumentFilePath].virtualCSharp.buf - vim.api.nvim_set_option_value("ft", "cs", { buf = targetBuf }) - for _, change in ipairs(result.changes) do - if wasEmpty then - change.newText = change.newText .. "\n// " .. result.hostDocumentVersion - local lines = vim.fn.split(change.newText, "\n") - vim.api.nvim_buf_set_lines(targetBuf, 0, -1, false, lines) - break - else - local currentText = table.concat(vim.api.nvim_buf_get_lines(targetBuf, 0, -1, false), "\n") - local before = vim.fn.strpart(currentText, 0, change.span.start) - local after = vim.fn.strpart(currentText, change.span.start + change.span.length) - local newText = change.newText - local newContent = before .. newText .. after - local lines = vim.fn.split(newContent, "\n", true) - local lastLine = lines[#lines] - local version = string.match(lastLine, "// (%d+)") - if version ~= result.hostDocumentVersion then - lines[#lines] = "// " .. result.hostDocumentVersion - end - vim.api.nvim_buf_set_lines(targetBuf, 0, -1, false, lines) - end - vim.print( - "Updating C# buffer for " - .. result.hostDocumentFilePath - .. " from version " - .. projectedDocuments[result.hostDocumentFilePath].virtualCSharp.hostDocumentVersion - .. " to " - .. result.hostDocumentVersion - ) - projectedDocuments[result.hostDocumentFilePath].virtualCSharp.hostDocumentVersion = result.hostDocumentVersion - end -end - ----Updates the HTML buffer with the new content ----@param result VBufUpdate -function M.update_html_vbuf(result) - local wasEmpty = result.previousWasEmpty - local targetBuf = projectedDocuments[result.hostDocumentFilePath].virtualHTML.buf - vim.api.nvim_set_option_value("ft", "html", { buf = targetBuf }) - for _, change in ipairs(result.changes) do - if wasEmpty then - change.newText = change.newText .. "\n// " .. result.hostDocumentVersion - local lines = vim.fn.split(change.newText, "\n") - vim.api.nvim_buf_set_lines(targetBuf, 0, -1, false, lines) - break - else - local currentText = table.concat(vim.api.nvim_buf_get_lines(targetBuf, 0, -1, false), "\n") - local before = vim.fn.strpart(currentText, 0, change.span.start) - local after = vim.fn.strpart(currentText, change.span.start + change.span.length) - local newText = change.newText - local newContent = before .. newText .. after - local lines = vim.fn.split(newContent, "\n", true) - local lastLine = lines[#lines] - local version = string.match(lastLine, "// (%d+)") - if version ~= result.hostDocumentVersion then - lines[#lines] = "// " .. result.hostDocumentVersion - end - vim.api.nvim_buf_set_lines(targetBuf, 0, -1, false, lines) - end - vim.print( - "Updating HTML buffer for " - .. result.hostDocumentFilePath - .. " from version " - .. projectedDocuments[result.hostDocumentFilePath].virtualHTML.hostDocumentVersion - .. " to " - .. result.hostDocumentVersion - ) - projectedDocuments[result.hostDocumentFilePath].virtualHTML.hostDocumentVersion = result.hostDocumentVersion - end -end - ----Creates virtual buffers for the given source buffer ----@param source_buf integer -function M.create_vbufs(source_buf) - local currentFile = vim.api.nvim_buf_get_name(source_buf) - vim.print("Creating virtual buffers for " .. currentFile) - --open virtual files - local virtualHTML = vim.api.nvim_create_buf(true, false) - vim.api.nvim_buf_set_name(virtualHTML, currentFile .. projectedHTMLSuffix) - vim.print("Virtual HTML buffer: " .. virtualHTML) - local virtualCSharp = vim.api.nvim_create_buf(true, false) - vim.api.nvim_buf_set_name(virtualCSharp, currentFile .. projectedCSharpSuffix) - vim.print("Virtual C# buffer: " .. virtualCSharp) - projectedDocuments[currentFile] = { - virtualHTML = { buf = virtualHTML, hostDocumentVersion = 0 }, - virtualCSharp = { buf = virtualCSharp, hostDocumentVersion = 0 }, - } -end - ----Converts a RPC return URI to a file path ----@param uri string ----@return string -local function uri_to_path(uri) - local path = uri:gsub("file://", "") - return path -end - ----Gets the virtual buffer number for the given URI ----@param uri string ----@param version integer ----@param type "html" | "csharp" ----@return integer | nil -function M.get_virtual_bufnr(uri, version, type) - local path = uri_to_path(uri) - local file = projectedDocuments[path] - - if not file then - return nil - end - if type == "html" then - -- if file.virtualHTML.hostDocumentVersion == version then - return file.virtualHTML.buf - -- end - end - - if type == "csharp" then - -- if file.virtualCSharp.hostDocumentVersion == version then - return file.virtualCSharp.buf - -- end - end - - return nil -end - -return M diff --git a/lua/rzls/handlers/completion.lua b/lua/rzls/handlers/completion.lua new file mode 100644 index 0000000..5b04d5d --- /dev/null +++ b/lua/rzls/handlers/completion.lua @@ -0,0 +1,54 @@ +local documentstore = require("rzls.documentstore") + +---@param err lsp.ResponseError +---@param result razor.DelegatedCompletionParams +---@param _ctx lsp.HandlerContext +---@param _config table +return function(err, result, _ctx, _config) + assert(not err, err) + + local virtual_document = documentstore.get_virtual_document( + result.identifier.textDocumentIdentifier.uri, + result.identifier.version, + result.projectedKind + ) + assert(virtual_document, "No virtual document found") + + local virtual_client = virtual_document:get_lsp_client() + assert(virtual_client, "No virtual client found") + + --- "@" is not a valid trigger character for C# and HTML + local trigger_character = result.context.triggerCharacter == "@" and result.context.triggerCharacter or nil + local trigger_kind = result.context.triggerCharacter == "@" and result.context.triggerKind or 1 -- Invoked + + ---@type lsp.CompletionParams | nio.lsp.types.CompletionParams + local params = { + context = { + triggerKind = trigger_kind, + triggerCharacter = trigger_character, + }, + position = result.projectedPosition, + textDocument = { + uri = vim.uri_from_bufnr(virtual_document.buf), + }, + } + + local response = + virtual_client.request_sync(vim.lsp.protocol.Methods.textDocument_completion, params, nil, virtual_document.buf) + + if response == nil then + return nil, + vim.lsp.rpc_response_error( + vim.lsp.client_errors["INVALID_SERVER_MESSAGE"], + "Virtual LSP client returned no response" + ) + end + + if response.err ~= nil then + return nil, err + end + + return response.result or { + items = {}, + } +end diff --git a/lua/rzls/handlers/hover.lua b/lua/rzls/handlers/hover.lua new file mode 100644 index 0000000..ff55a7e --- /dev/null +++ b/lua/rzls/handlers/hover.lua @@ -0,0 +1,75 @@ +---@diagnostic disable: redefined-local +local nio = require("nio") +local documentstore = require("rzls.documentstore") + +---@param err lsp.ResponseError +---@param result nio.lsp.types.Hover +---@param ctx lsp.HandlerContext +---@param config table +return function(err, result, ctx, config) + nio.run(function() + assert(not err) + + if result then + vim.lsp.handlers.hover(err, result, ctx, config) + return + end + + ---@type nio.lsp.types.Position + local position = ctx.params.position + ---@type integer + local bufnr = ctx.bufnr + + local client = nio.lsp.get_client_by_id(ctx.client_id) + assert(client, "Could not find Razor Client") + + local ierr, language_query_response = client.request.razor_languageQuery({ + position = position, + uri = vim.uri_from_bufnr(bufnr), + }) + assert(not ierr, vim.inspect(ierr)) + assert(language_query_response) + + local virtual_document = documentstore.get_virtual_document( + vim.uri_from_bufnr(bufnr), + language_query_response.hostDocumentVersion, + language_query_response.kind + ) + assert(virtual_document) + + local virtual_buf_client = virtual_document:get_nio_lsp_client() + + if virtual_buf_client == nil then + vim.lsp.handlers.hover(err, result, ctx, config) + return + end + + local errh, hover_result = virtual_buf_client.request.textDocument_hover({ + textDocument = { + uri = vim.uri_from_bufnr(virtual_document.buf), + "textHover from virtual buffer uri", + }, + position = language_query_response.position, + }) + assert(not errh, vim.inspect(errh)) + + if hover_result == nil then + vim.lsp.handlers.hover(err, hover_result, ctx, config) + return + end + + local errm, response = client.request.razor_mapToDocumentRanges({ + razorDocumentUri = vim.uri_from_bufnr(bufnr), + kind = language_query_response.kind, + projectedRanges = { hover_result.range }, + }) + assert(not errm, vim.inspect(errm)) + + if response ~= nil and response.ranges[1] ~= nil then + vim.lsp.handlers.hover(err, { + contents = hover_result.contents, + range = response.ranges[1], + }, ctx, config) + end + end) +end diff --git a/lua/rzls/handlers/init.lua b/lua/rzls/handlers/init.lua index a40dcbf..6d48e63 100644 --- a/lua/rzls/handlers/init.lua +++ b/lua/rzls/handlers/init.lua @@ -1,6 +1,8 @@ local documentstore = require("rzls.documentstore") -local lsp = require("rzls.utils.lsp") +local lsp_util = require("rzls.utils.lsp") local dsu = require("rzls.utils.documentstore") +local razor = require("rzls.razor") +local nio = require("nio") local not_implemented = function(err, result, ctx, config) vim.print("Called" .. ctx.method) @@ -30,42 +32,77 @@ return { ["razor/mapCode"] = not_implemented, -- VS Windows and VS Code - ["razor/updateCSharpBuffer"] = function(_err, result, _ctx, _config) - documentstore.update_csharp_vbuf(result) - --NOTE: ["razor/updateCSharpBuffer"] = DONE + ---@param err lsp.ResponseError + ---@param result VBufUpdate + ---@param _ctx lsp.HandlerContext + ---@param _config? table + ---@return razor.ProvideSemanticTokensResponse|nil + ---@return lsp.ResponseError|nil + ["razor/updateCSharpBuffer"] = function(err, result, _ctx, _config) + assert(not err, vim.inspect(err)) + documentstore.update_vbuf(result, razor.language_kinds.csharp) end, - ["razor/updateHtmlBuffer"] = function(_err, result, _ctx, _config) - documentstore.update_html_vbuf(result) - --NOTE: ["razor/updateHtmlBuffer"] = DONE + ---@param err lsp.ResponseError + ---@param result VBufUpdate + ---@param _ctx lsp.HandlerContext + ---@param _config? table + ---@return razor.ProvideSemanticTokensResponse|nil + ---@return lsp.ResponseError|nil + ["razor/updateHtmlBuffer"] = function(err, result, _ctx, _config) + assert(not err, vim.inspect(err)) + documentstore.update_vbuf(result, razor.language_kinds.html) end, ["razor/provideCodeActions"] = not_implemented, ["razor/resolveCodeActions"] = not_implemented, ["razor/provideHtmlColorPresentation"] = not_supported, - ["razor/provideHtmlDocumentColor"] = function(err, result, _ctx, _config) + ["razor/provideHtmlDocumentColor"] = function(err, _result, _ctx, _config) if err then vim.print("Error in razor/provideHtmlDocumentColor") return {}, nil end - local _targetDoc = result.textDocument.uri - local _targetVersion = result._razor_hostDocumentVersion + -- local _targetDoc = result.textDocument.uri + -- local _targetVersion = result._razor_hostDocumentVersion --TODO: Function that will look through the virtual HTML buffer and return color locations return {}, nil end, - ["razor/provideSemanticTokensRange"] = not_implemented, + ---@param err lsp.ResponseError + ---@param result razor.ProvideSemanticTokensParams + ---@param _ctx lsp.HandlerContext + ---@param _config? table + ---@return razor.ProvideSemanticTokensResponse|nil + ---@return lsp.ResponseError|nil + ["razor/provideSemanticTokensRange"] = function(err, result, _ctx, _config) + nio.run(function() + assert(not err, err) + + local virtual_document = documentstore.get_virtual_document( + result.textDocument.uri, + result.requiredHostDocumentVersion, + razor.language_kinds.csharp + ) + assert(virtual_document, "Could not find virtual document") + + -- local virtual_buf_client = nio.lsp.get_clients({ bufnr = virtual_document.buf })[1] + end) + end, ["razor/foldingRange"] = not_implemented, ["razor/htmlFormatting"] = function(err, result, _ctx, _config) if err then - vim.print("Error in razor/htmlFormatting") + -- vim.print("Error in razor/htmlFormatting") return {}, nil end - local bufnr = - documentstore.get_virtual_bufnr(result.textDocument.uri, result._razor_hostDocumentVersion, "html") + local vd = documentstore.get_virtual_document( + result.textDocument.uri, + result._razor_hostDocumentVersion, + razor.language_kinds.html + ) + local bufnr = vd.buf if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then - vim.print("No virtual buffer found") + -- vim.print("No virtual buffer found") return {}, nil end - local client = lsp.get_client("html") + local client = lsp_util.get_client("html") if not client then return {}, nil end @@ -73,9 +110,9 @@ return { local filename = vim.api.nvim_buf_get_name(bufnr) local linecount = dsu.get_virtual_lines_count(bufnr) local virtual_htmluri = "file://" .. filename - vim.print("Formatting virtual HTML buffer: " .. virtual_htmluri) - vim.print("Range to line " .. linecount) - vim.print(vim.inspect(result.options)) + -- vim.print("Formatting virtual HTML buffer: " .. virtual_htmluri) + -- vim.print("Range to line " .. linecount) + -- vim.print(vim.inspect(result.options)) client.request("textDocument/rangeFormatting", { textDocument = { uri = virtual_htmluri }, range = { @@ -90,10 +127,10 @@ return { }, options = result.options, }, function(suberr, subresult, _subctx, _subconfig) - vim.print("Formatting virtual HTML buffer: " .. virtual_htmluri .. " DONE") + -- vim.print("Formatting virtual HTML buffer: " .. virtual_htmluri .. " DONE") if suberr then - vim.print("Error in subformatting request") - vim.print(vim.inspect(suberr)) + -- vim.print("Error in subformatting request") + -- vim.print(vim.inspect(suberr)) return {}, nil end response = subresult @@ -102,11 +139,11 @@ return { local i = 0 while not response do -- HACK: Make this not ugly and properly wait - vim.print("Waiting for response") + -- vim.print("Waiting for response") vim.wait(100) i = i + 1 if i > 100 then - vim.print("Timeout") + -- vim.print("Timeout") break end end @@ -114,7 +151,7 @@ return { -- [WARN][2024-06-05 21:54:34] ...lsp/handlers.lua:626 "[LSP][LanguageServer.Formatting.FormattingContentValidationPass] A format operation is being abandoned because it would add or delete non-whitespace content." -- [WARN][2024-06-05 21:54:34] ...lsp/handlers.lua:626 "[LSP][LanguageServer.Formatting.FormattingContentValidationPass] Edit at (0, 0)-(16, 0) adds the non-whitespace content 'REDACTED'." -- Need to make the returned edits valid - vim.print(vim.inspect(response)) + -- vim.print(vim.inspect(response)) return { edits = response }, nil end, ["razor/htmlOnTypeFormatting"] = not_implemented, @@ -136,6 +173,6 @@ return { -- Called to get C# diagnostics from Roslyn when publishing diagnostics for VS Code ["razor/csharpPullDiagnostics"] = not_implemented, ["textDocument/colorPresentation"] = not_supported, - - ["razor/initialize"] = not_implemented, + ["razor/completion"] = require("rzls.handlers.completion"), + [vim.lsp.protocol.Methods.textDocument_hover] = require("rzls.handlers.hover"), } diff --git a/lua/rzls/handlers/types.lua b/lua/rzls/handlers/types.lua index aa88f84..7cc4089 100644 --- a/lua/rzls/handlers/types.lua +++ b/lua/rzls/handlers/types.lua @@ -22,7 +22,7 @@ local M = {} ---@field previousWasEmpty boolean ---@field hostDocumentFilePath string ---@field hostDocumentVersion number ----@field changes tableA +---@field changes Change[] ---@class Change ---@field span Span diff --git a/lua/rzls/init.lua b/lua/rzls/init.lua index 9db0517..3fc6116 100644 --- a/lua/rzls/init.lua +++ b/lua/rzls/init.lua @@ -1,12 +1,13 @@ -local M = {} local handlers = require("rzls.handlers") +local documentstore = require("rzls.documentstore") + +local M = {} ---@class rzls.Config ---@field on_attach function ---@field capabilities table ---@field path string -local rzlsconfig = {} ---@type rzls.Config local defaultConfg = { on_attach = function() @@ -16,25 +17,29 @@ local defaultConfg = { path = "/tmp", } +---@type lsp.ClientCapabilities local extraCapabilities = { colorProvider = true, } function M.setup(config) - rzlsconfig = vim.tbl_deep_extend("force", defaultConfg, config) -end + local rzlsconfig = vim.tbl_deep_extend("force", defaultConfg, config) + vim.filetype.add({ + extension = { + razor = "razor", + }, + }) -local au = vim.api.nvim_create_augroup("rzls", { clear = true }) + local au = vim.api.nvim_create_augroup("rzls", { clear = true }) -vim.api.nvim_create_autocmd("BufRead", { - pattern = "*.razor", - callback = function() - local lspClientID = nil - if not lspClientID then - lspClientID = vim.lsp.start({ + vim.api.nvim_create_autocmd("FileType", { + pattern = "razor", + callback = function(ev) + local root_dir = vim.fn.getcwd() + local lsp_client_id = vim.lsp.start({ name = "rzls", cmd = { - rzlsconfig.path .. "/rzls", + rzlsconfig.path, "--logLevel", "0", "--DelegateToCSharpOnDiagnosticsPublish", @@ -42,20 +47,59 @@ vim.api.nvim_create_autocmd("BufRead", { "--UpdateBuffersForClosedDocuments", "true", }, - root_dir = vim.fn.getcwd(), - on_attach = rzlsconfig.on_attach, + on_init = function(client, _initialize_result) + M.load_existing_files(root_dir) + M.watch_new_files(root_dir) + documentstore.initialize(client, root_dir) + end, + root_dir = root_dir, + on_attach = function(client, bufnr) + documentstore.initialize(client, root_dir) + documentstore.register_vbufs(bufnr) + rzlsconfig.on_attach(client, bufnr) + end, capabilities = vim.tbl_deep_extend("force", rzlsconfig.capabilities, extraCapabilities), - settings = { razor = vim.empty_dict(), html = vim.empty_dict() }, + settings = { + ["razor.server.trace"] = "Trace", + html = vim.empty_dict(), + razor = vim.empty_dict(), + ["vs.editor.razor"] = vim.empty_dict(), + }, handlers = handlers, }) - end - require("rzls.documentstore").create_vbufs(vim.api.nvim_get_current_buf()) - if lspClientID then - vim.lsp.buf_attach_client(0, lspClientID) - vim.notify("Razor LSP attached", vim.log.levels.INFO, { title = "rzls.nvim" }) + if lsp_client_id == nil then + vim.notify("Could not start Razor LSP", vim.log.levels.ERROR, { title = "rzls.nvim" }) + return + end + + vim.lsp.buf_attach_client(ev.buf, lsp_client_id) + end, + group = au, + }) +end + +function M.load_existing_files(path) + local files = vim.fn.glob(path .. "/**/*.razor", true, true) + for _, file in ipairs(files) do + documentstore.register_vbufs_by_path(file) + end +end + +function M.watch_new_files(path) + local w = vim.uv.new_fs_event() + assert(w) + + local fullpath = vim.fn.fnamemodify(path, ":p") + + w:start(fullpath, { + recursive = true, + }, function(err, filename, _events) + assert(not err, err) + vim.print("file modified:" .. filename) + if vim.fn.fnamemodify(filename, ":e") == "razor" then + documentstore.register_vbufs_by_path(filename) end - end, - group = au, -}) + end) +end return M diff --git a/lua/rzls/nio.lua b/lua/rzls/nio.lua new file mode 100644 index 0000000..baa4157 --- /dev/null +++ b/lua/rzls/nio.lua @@ -0,0 +1,23 @@ +---@module "nio" +---@class nio.lsp.RequestClient +local LSPRequestClient = {} + +--- Query to find which language kind an specified position in a razor file represents +---@async +---@param _args razor.LanguageQueryParams Arguments to the request +---@param _bufnr integer? Buffer number (0 for current buffer) +---@param _opts? nio.lsp.RequestOpts Options for the request handling +---@return nio.lsp.types.ResponseError|nil error The error object in case a request fails. +---@return razor.LanguageQueryResponse|nil result The result of the request +function LSPRequestClient.razor_languageQuery(_args, _bufnr, _opts) end + +--- Map ranges from the projected document to the razor document +---@async +---@param _args razor.MapToDocumentRangesParams Arguments to the request +---@param _bufnr integer? Buffer number (0 for current buffer) +---@param _opts? nio.lsp.RequestOpts Options for the request handling +---@return nio.lsp.types.ResponseError|nil error The error object in case a request fails. +---@return razor.MapToDocumentRangesResponse|nil result The result of the request +function LSPRequestClient.razor_mapToDocumentRanges(_args, _bufnr, _opts) end + +return LSPRequestClient diff --git a/lua/rzls/razor.lua b/lua/rzls/razor.lua new file mode 100644 index 0000000..d2f2dad --- /dev/null +++ b/lua/rzls/razor.lua @@ -0,0 +1,89 @@ +local M = {} +---@module "nio" + +---@class razor.LanguageQueryParams +---@field position nio.lsp.types.Position +---@field uri string + +---@class razor.LanguageQueryResponse +---@field hostDocumentVersion integer +---@field kind razor.LanguageKind +---@field position nio.lsp.types.Position +---@field positionIndex integer + +---@class razor.MapToDocumentRangesParams +---@field razorDocumentUri string +---@field kind razor.LanguageKind +---@field projectedRanges nio.lsp.types.Range[] + +---@class razor.MapToDocumentRangesResponse +---@field hostDocumentVersion integer +---@field ranges nio.lsp.types.Range[] + +---@class razor.ProvideSemanticTokensParams +---@field correlationId string +---@field textDocument nio.lsp.types.TextDocumentIdentifier +---@field ranges nio.lsp.types.Range[] +---@field requiredHostDocumentVersion integer + +---@class razor.ProvideSemanticTokensResponse +---@field tokens integer[] +---@field hostDocumentSyncVersion integer + +---@class razor.DelegatedCompletionRange +---@field tokens integer[] +---@field hostDocumentSyncVersion integer + +---@class razor.DelegatedCompletionParams +---@field identifier { textDocumentIdentifier: nio.lsp.types.TextDocumentIdentifier, version: integer } +---@field projectedPosition nio.lsp.types.Position +---@field projectedKind razor.LanguageKind +---@field context nio.lsp.types.CompletionContext +---@field provisionalTextEdit nio.lsp.types.TextEdit +---@field shouldIncludeSnippets boolean + +---@class razor.ProvideDynamicFileParams +---@field razorDocument nio.lsp.types.TextDocumentIdentifier + +---@class razor.ProvideDynamicFileResponse +---@field csharpDocument nio.lsp.types.TextDocumentIdentifier|nil + +---@enum razor.LanguageKind +M.language_kinds = { + csharp = 1, + html = 2, + razor = 3, +} + +---@enum razor.LSPClientName +M.lsp_names = { + [M.language_kinds.html] = "html", + [M.language_kinds.csharp] = "roslyn", + [M.language_kinds.razor] = "rzls", +} + +---@param lsp vim.lsp.Client +---@param bufnr integer +---@param position lsp.Position +---@return { err: lsp.ResponseError|nil, result: razor.LanguageQueryResponse }|nil, string|nil +function M.language_query_sync(lsp, bufnr, position) + return lsp.request_sync("razor/languageQuery", { + position = position, + uri = vim.uri_from_bufnr(bufnr), + }, nil, bufnr) +end + +---@param lsp vim.lsp.Client +---@param bufnr integer +---@param language_kind razor.LanguageKind +---@param ranges lsp.Range[] +---@param cb fun(err: lsp.ResponseError, response: razor.MapToDocumentRangesResponse) +function M.map_to_document_ranges(lsp, bufnr, language_kind, ranges, cb) + lsp.request("razor/mapToDocumentRanges", { + razorDocumentUri = vim.uri_from_bufnr(bufnr), + kind = language_kind, + projectedRanges = ranges, + }, cb) +end + +return M diff --git a/lua/rzls/roslyn_handlers.lua b/lua/rzls/roslyn_handlers.lua new file mode 100644 index 0000000..afb0eed --- /dev/null +++ b/lua/rzls/roslyn_handlers.lua @@ -0,0 +1,36 @@ +local documentstore = require("rzls.documentstore") +local razor = require("rzls.razor") + +---@param _err lsp.ResponseError +---@param result razor.ProvideDynamicFileParams +---@param _ctx lsp.HandlerContext +---@param _config? table +---@return razor.ProvideDynamicFileResponse|nil +---@return lsp.ResponseError|nil +local function roslyn_razor_provideDynamicFileHandler(_err, result, _ctx, _config) + if result.razorDocument == nil then + return nil, vim.lsp.rpc.rpc_response_error(-32602, "Missing razorDocument") + end + local vd = documentstore.get_virtual_document(result.razorDocument.uri, 0, razor.language_kinds.csharp) + local bufnr = vd.buf + + if bufnr == nil then + vim.print(vim.inspect(result)) + return nil, vim.lsp.rpc.rpc_response_error(-32600, "Could not find requested document") + end + + -- TODO: ideally we could get the client by the razor document, but the client might no have been attached yet + local razor_client = vim.lsp.get_clients({ name = "rzls" })[1] + assert(razor_client, "Could not find razor client") + documentstore.rosyln_is_ready(razor_client) + + return { + csharpDocument = { + uri = vim.uri_from_bufnr(bufnr), + }, + } +end + +return { + ["razor/provideDynamicFileInfo"] = roslyn_razor_provideDynamicFileHandler, +} diff --git a/lua/rzls/utils/init.lua b/lua/rzls/utils/init.lua new file mode 100644 index 0000000..f65e903 --- /dev/null +++ b/lua/rzls/utils/init.lua @@ -0,0 +1,35 @@ +local M = {} + +---@generic T +---@param val T +---@param message string? +---@return T +function M.debug(val, message) + if true then + local prefix = message and message .. ": " or "" + vim.print(prefix .. vim.inspect(val)) + end + return val +end + +local eols = { + dos = "\r\n", + unix = "\n", + mac = "\r", +} + +---@param bufnr integer +function M.buffer_eol(bufnr) + return eols[vim.bo[bufnr].fileformat] +end + +math.randomseed(tonumber(tostring(os.time()):reverse():sub(1, 9))) +function M.uuid() + local template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx" + return string.gsub(template, "[xy]", function(c) + local v = (c == "x") and math.random(0, 0xf) or math.random(8, 0xb) + return string.format("%x", v) + end) +end + +return M diff --git a/lua/rzls/utils/lsp.lua b/lua/rzls/utils/lsp.lua index 2e2d281..ee102d6 100644 --- a/lua/rzls/utils/lsp.lua +++ b/lua/rzls/utils/lsp.lua @@ -11,4 +11,13 @@ function M.get_client(name) return client[1] end +---@param cursor_pos integer[] +---@return lsp.Position +function M.cursor_to_lsp_position(cursor_pos) + return { + line = cursor_pos[1] - 1, + character = cursor_pos[2], + } +end + return M diff --git a/lua/rzls/virtual_document.lua b/lua/rzls/virtual_document.lua new file mode 100644 index 0000000..c472f6f --- /dev/null +++ b/lua/rzls/virtual_document.lua @@ -0,0 +1,69 @@ +local razor = require("rzls.razor") +local nio = require("nio") + +---@class rzls.VirtualDocument +---@field buf number +---@field path string +---@field host_document_version number +---@field content string +---@field kind razor.LanguageKind +local VirtualDocument = {} + +VirtualDocument.__index = VirtualDocument + +---@param bufnr integer|string +---@param kind razor.LanguageKind +---@return rzls.VirtualDocument +function VirtualDocument:new(bufnr, kind) + if type(bufnr) == "string" then + -- TODO: should we open the buffer here to attach rzls to create the vbufs? + local virtual_document = setmetatable({ + buf = -1, + host_document_version = 0, + content = "", + path = bufnr, + kind = kind, + }, self) + return virtual_document + elseif type(bufnr) == "number" then + local virtual_document = setmetatable({ + buf = bufnr, + host_document_version = 0, + content = "", + path = vim.api.nvim_buf_get_name(bufnr), + kind = kind, + }, self) + return virtual_document + end + error("Invalid buffer number") +end + +---@param content string +---@param change Change +local function apply_change(content, change) + local before = vim.fn.strcharpart(content, 0, change.span.start) + local after = vim.fn.strcharpart(content, change.span.start + change.span.length) + + return before .. change.newText .. after +end + +---@param result VBufUpdate +function VirtualDocument:update_content(result) + for _, change in ipairs(vim.fn.reverse(result.changes)) do + self.content = apply_change(self.content, change) + end + + self.host_document_version = result.hostDocumentVersion +end + +---@return vim.lsp.Client|nil +function VirtualDocument:get_lsp_client() + return vim.lsp.get_clients({ bufnr = self.buf, name = razor.lsp_names[self.kind] })[1] +end + +---@return nio.lsp.Client|nil +function VirtualDocument:get_nio_lsp_client() + return nio.lsp.get_clients({ bufnr = self.buf, name = razor.lsp_names[self.kind] })[1] +end + +return VirtualDocument diff --git a/tests/minimal.lua b/tests/minimal.lua new file mode 100644 index 0000000..64d7a32 --- /dev/null +++ b/tests/minimal.lua @@ -0,0 +1,36 @@ +local M = {} + +local function tempdir(plugin) + if jit.os == "Windows" then + return "D:\\tmp\\" .. plugin + end + return vim.loop.os_tmpdir() .. "/" .. plugin +end + +local plenary_dir = os.getenv("PLENARY_DIR") or tempdir("plenary.nvim") +if vim.fn.isdirectory(plenary_dir) == 0 then + vim.fn.system({ + "git", + "clone", + "https://github.com/nvim-lua/plenary.nvim", + plenary_dir, + }) +end +vim.opt.rtp:append(".") +vim.opt.rtp:append(plenary_dir) +require("plenary.busted") + + +local nio_dir = os.getenv("NIO_DIR") or tempdir("nvim-nio") +if vim.fn.isdirectory(nio_dir) == 0 then + vim.fn.system({ + "git", + "clone", + "https://github.com/nvim-neotest/nvim-nio", + nio_dir, + }) +end +vim.opt.rtp:append(nio_dir) + +vim.cmd("runtime plugin/plenary.vim") +return M diff --git a/tests/rzls/fixtures/test.razor__virtual.html b/tests/rzls/fixtures/test.razor__virtual.html new file mode 100644 index 0000000..ce01362 --- /dev/null +++ b/tests/rzls/fixtures/test.razor__virtual.html @@ -0,0 +1 @@ +hello diff --git a/tests/rzls/utils_init_spec.lua b/tests/rzls/utils_init_spec.lua new file mode 100644 index 0000000..42993a0 --- /dev/null +++ b/tests/rzls/utils_init_spec.lua @@ -0,0 +1,19 @@ +---@diagnostic disable-next-line: undefined-field +local eq = assert.are.same +---@diagnostic disable-next-line: undefined-field +local neq = assert.are_not.same + +describe("uuid", function() + it("generates unique uuids", function() + local utils = require("rzls.utils") + local uuid1 = utils.uuid() + local uuid2 = utils.uuid() + neq(uuid1, uuid2) + end) + it("generates valid uuids", function() + -- 5f72f119-e7b2-433b-b919-df8a74866e45 + local utils = require("rzls.utils") + local uuid = utils.uuid() + eq(36, #uuid) + end) +end) diff --git a/tests/rzls/virtual_document_spec.lua b/tests/rzls/virtual_document_spec.lua new file mode 100644 index 0000000..0941b2e --- /dev/null +++ b/tests/rzls/virtual_document_spec.lua @@ -0,0 +1,149 @@ +local virtual_document = require("rzls.virtual_document") +local razor = require("rzls.razor") +---@diagnostic disable-next-line: undefined-field +local eq = assert.are.same + +describe("virtual document", function() + it("create virtual document", function() + local path = "tests/rzls/fixtures/test.razor__virtual.html" + local full_path = vim.loop.cwd() .. "/" .. path + vim.cmd.edit({ args = { path } }) + local ls = vim.fn.getbufinfo({ buflisted = 1 }) + local bufnr = ls[1].bufnr + local vd = virtual_document:new(bufnr, razor.language_kinds.html) + eq({ + buf = bufnr, + host_document_version = 0, + content = "", + kind = razor.language_kinds.html, + path = full_path, + }, vd) + + vd:update_content({ + previousWasEmpty = true, + hostDocumentVersion = 1, + hostDocumentFilePath = full_path, + changes = { + { + newText = "Hello\n", + span = { + start = 0, + length = 0, + }, + }, + }, + }) + eq({ + buf = bufnr, + host_document_version = 1, + content = "Hello\n", + kind = razor.language_kinds.html, + path = full_path, + }, vd) + vd:update_content({ + previousWasEmpty = false, + hostDocumentVersion = 2, + hostDocumentFilePath = full_path, + changes = { + { + newText = " World\n", + span = { + start = 5, + length = 1, + }, + }, + }, + }) + eq({ + buf = bufnr, + host_document_version = 2, + content = "Hello World\n", + kind = razor.language_kinds.html, + path = full_path, + }, vd) + vd:update_content({ + previousWasEmpty = false, + hostDocumentVersion = 3, + hostDocumentFilePath = full_path, + changes = { + { + newText = "stuff\n", + span = { + start = 6, + length = 6, + }, + }, + }, + }) + eq({ + buf = bufnr, + host_document_version = 3, + content = "Hello stuff\n", + kind = razor.language_kinds.html, + path = full_path, + }, vd) + vd:update_content({ + previousWasEmpty = false, + hostDocumentVersion = 4, + hostDocumentFilePath = full_path, + changes = { + { + newText = "in the middle ", + span = { + start = 6, + length = 0, + }, + }, + }, + }) + eq({ + buf = bufnr, + host_document_version = 4, + content = "Hello in the middle stuff\n", + kind = razor.language_kinds.html, + path = full_path, + }, vd) + vd:update_content({ + previousWasEmpty = false, + hostDocumentVersion = 5, + hostDocumentFilePath = full_path, + changes = { + { + newText = "i💩\n", + span = { + start = 0, + length = 0, + }, + }, + }, + }) + eq({ + buf = bufnr, + host_document_version = 5, + content = "i💩\nHello in the middle stuff\n", + kind = razor.language_kinds.html, + path = full_path, + }, vd) + vd:update_content({ + previousWasEmpty = false, + hostDocumentVersion = 6, + hostDocumentFilePath = full_path, + changes = { + { + newText = "", + span = { + start = 0, + length = 3, + }, + }, + }, + }) + eq({ + buf = bufnr, + host_document_version = 6, + content = "Hello in the middle stuff\n", + kind = razor.language_kinds.html, + path = full_path, + }, vd) + end) +end)