Files
neovim/test/functional/fixtures/fake-lsp-server.lua
skewb1k 40aef0d02e fix(lsp): decode 'null' in server responses as vim.NIL #34849
Problem:
Previously, 'null' value in LSP responses were decoded as 'nil'.
This caused ambiguity for fields typed as '? | null' and led to
loss of explicit 'null' values, particularly in 'data' parameters.

Solution:
Decode all JSON 'null' values as 'vim.NIL' and adjust handling
where needed. This better aligns with the LSP specification,
where 'null' and absent fields are distinct, and 'null' should
not be used to represent missing values.

This also enables proper validation of response messages to
ensure that exactly one of 'result' or 'error' is present, as
required by the JSON-RPC specification.
2025-08-03 07:42:44 -07:00

1065 lines
26 KiB
Lua

local protocol = require 'vim.lsp.protocol'
-- Logs to $NVIM_LOG_FILE.
--
-- TODO(justinmk): remove after https://github.com/neovim/neovim/pull/7062
local function log(loglevel, area, msg)
vim.fn.writefile({ string.format('%s %s: %s', loglevel, area, msg) }, vim.env.NVIM_LOG_FILE, 'a')
end
local function message_parts(sep, ...)
local parts = {}
for i = 1, select('#', ...) do
local arg = select(i, ...)
if arg ~= nil then
table.insert(parts, arg)
end
end
return table.concat(parts, sep)
end
-- Assert utility methods
local function assert_eq(a, b, ...)
if not vim.deep_equal(a, b) then
error(
message_parts(
': ',
...,
'assert_eq failed',
string.format(
'left == %q, right == %q',
table.concat(vim.split(vim.inspect(a), '\n'), ''),
table.concat(vim.split(vim.inspect(b), '\n'), '')
)
)
)
end
end
local function format_message_with_content_length(encoded_message)
return table.concat {
'Content-Length: ',
tostring(#encoded_message),
'\r\n\r\n',
encoded_message,
}
end
local function read_message()
local line = io.read('*l')
local length = line:lower():match('content%-length:%s*(%d+)')
return vim.json.decode(io.read(2 + length):sub(2))
end
local function send(payload)
io.stdout:write(format_message_with_content_length(vim.json.encode(payload)))
end
local function respond(id, err, result)
assert(type(id) == 'number', 'id must be a number')
send { jsonrpc = '2.0', id = id, error = err, result = result }
end
local function notify(method, params)
assert(type(method) == 'string', 'method must be a string')
send { method = method, params = params or {} }
end
local function expect_notification(method, params, ...)
local message = read_message()
assert_eq(method, message.method, ..., 'expect_notification', 'method')
if params then
assert_eq(params, message.params, ..., 'expect_notification', method, 'params')
assert_eq(
{ jsonrpc = '2.0', method = method, params = params },
message,
...,
'expect_notification',
'message'
)
end
end
local function expect_request(method, handler, ...)
local req = read_message()
assert_eq(method, req.method, ..., 'expect_request', 'method')
local err, result = handler(req.params)
respond(req.id, err, result)
end
io.stderr:setvbuf('no')
local function skeleton(config)
local on_init = assert(config.on_init)
local body = assert(config.body)
expect_request('initialize', function(params)
return nil, on_init(params)
end)
expect_notification('initialized', {})
body()
expect_request('shutdown', function()
return nil, {}
end)
expect_notification('exit', nil)
end
-- The actual tests.
local tests = {}
function tests.basic_init()
skeleton {
on_init = function(params)
assert_eq(params.workDoneToken, '1')
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.None,
},
}
end,
body = function()
notify('test')
end,
}
end
function tests.basic_init_did_change_configuration()
skeleton({
on_init = function(_)
return {
capabilities = {},
}
end,
body = function()
expect_notification('workspace/didChangeConfiguration', { settings = { dummy = 1 } })
end,
})
end
function tests.check_workspace_configuration()
skeleton {
on_init = function(_params)
return { capabilities = {} }
end,
body = function()
notify('start')
notify('workspace/configuration', {
items = {
{ section = 'testSetting1' },
{ section = 'testSetting2' },
{ section = 'test.Setting3' },
{ section = 'test.Setting4' },
},
})
expect_notification('workspace/configuration', { true, false, 'nested', vim.NIL })
notify('shutdown')
end,
}
end
function tests.prepare_rename_nil()
skeleton {
on_init = function()
return {
capabilities = {
renameProvider = {
prepareProvider = true,
},
},
}
end,
body = function()
notify('start')
expect_request('textDocument/prepareRename', function()
return {}, nil
end)
notify('shutdown')
end,
}
end
function tests.prepare_rename_placeholder()
skeleton {
on_init = function()
return {
capabilities = {
renameProvider = {
prepareProvider = true,
},
},
}
end,
body = function()
notify('start')
expect_request('textDocument/prepareRename', function()
return nil, { placeholder = 'placeholder' }
end)
expect_request('textDocument/rename', function(params)
assert_eq(params.newName, 'renameto')
return {}, nil
end)
notify('shutdown')
end,
}
end
function tests.prepare_rename_range()
skeleton {
on_init = function()
return {
capabilities = {
renameProvider = {
prepareProvider = true,
},
},
}
end,
body = function()
notify('start')
expect_request('textDocument/prepareRename', function()
return nil,
{
start = { line = 1, character = 8 },
['end'] = { line = 1, character = 12 },
}
end)
expect_request('textDocument/rename', function(params)
assert_eq(params.newName, 'renameto')
return {}, nil
end)
notify('shutdown')
end,
}
end
function tests.prepare_rename_error()
skeleton {
on_init = function()
return {
capabilities = {
renameProvider = {
prepareProvider = true,
},
},
}
end,
body = function()
notify('start')
expect_request('textDocument/prepareRename', function()
return {}, nil
end)
notify('shutdown')
end,
}
end
function tests.basic_check_capabilities()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Full,
codeLensProvider = false,
},
}
end,
body = function() end,
}
end
function tests.text_document_save_did_open()
skeleton {
on_init = function()
return {
capabilities = {
textDocumentSync = {
save = true,
},
},
}
end,
body = function()
notify('start')
expect_notification('textDocument/didClose')
expect_notification('textDocument/didOpen')
expect_notification('textDocument/didSave')
notify('shutdown')
end,
}
end
function tests.text_document_sync_save_bool()
skeleton {
on_init = function()
return {
capabilities = {
textDocumentSync = {
save = true,
},
},
}
end,
body = function()
notify('start')
expect_notification('textDocument/didSave', { textDocument = { uri = 'file://' } })
notify('shutdown')
end,
}
end
function tests.text_document_sync_save_includeText()
skeleton {
on_init = function()
return {
capabilities = {
textDocumentSync = {
save = {
includeText = true,
},
},
},
}
end,
body = function()
notify('start')
expect_notification('textDocument/didSave', {
textDocument = {
uri = 'file://',
},
text = 'help me\n',
})
notify('shutdown')
end,
}
end
function tests.capabilities_for_client_supports_method()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Full,
completionProvider = true,
hoverProvider = true,
renameProvider = false,
definitionProvider = false,
referencesProvider = false,
codeLensProvider = { resolveProvider = true },
},
}
end,
body = function() end,
}
end
function tests.check_forward_request_cancelled()
skeleton {
on_init = function(_)
return { capabilities = {} }
end,
body = function()
expect_request('error_code_test', function()
return { code = -32800 }, nil, { method = 'error_code_test', client_id = 1 }
end)
notify('finish')
end,
}
end
function tests.check_forward_content_modified()
skeleton {
on_init = function(_)
return { capabilities = {} }
end,
body = function()
expect_request('error_code_test', function()
return { code = -32801 }, nil, { method = 'error_code_test', client_id = 1 }
end)
expect_notification('finish')
notify('finish')
end,
}
end
function tests.check_forward_server_cancelled()
skeleton {
on_init = function()
return { capabilities = {} }
end,
body = function()
expect_request('error_code_test', function()
return { code = -32802 }, nil, { method = 'error_code_test', client_id = 1 }
end)
expect_notification('finish')
notify('finish')
end,
}
end
function tests.check_pending_request_tracked()
skeleton {
on_init = function(_)
return { capabilities = {} }
end,
body = function()
local msg = read_message()
assert_eq('slow_request', msg.method)
expect_notification('release')
respond(msg.id, nil, {})
expect_notification('finish')
notify('finish')
end,
}
end
function tests.check_cancel_request_tracked()
skeleton {
on_init = function(_)
return { capabilities = {} }
end,
body = function()
local msg = read_message()
assert_eq('slow_request', msg.method)
expect_notification('$/cancelRequest', { id = msg.id })
expect_notification('release')
respond(msg.id, { code = -32800 }, nil)
notify('finish')
end,
}
end
function tests.check_tracked_requests_cleared()
skeleton {
on_init = function(_)
return { capabilities = {} }
end,
body = function()
local msg = read_message()
assert_eq('slow_request', msg.method)
expect_notification('$/cancelRequest', { id = msg.id })
expect_notification('release')
respond(msg.id, nil, {})
expect_notification('finish')
notify('finish')
end,
}
end
function tests.basic_finish()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Full,
},
}
end,
body = function()
expect_notification('finish')
notify('finish')
end,
}
end
function tests.basic_check_buffer_open()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Full,
},
}
end,
body = function()
notify('start')
expect_notification('textDocument/didOpen', {
textDocument = {
languageId = '',
text = table.concat({ 'testing', '123' }, '\n') .. '\n',
uri = 'file://',
version = 0,
},
})
expect_notification('finish')
notify('finish')
end,
}
end
function tests.basic_check_buffer_open_and_change()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Full,
},
}
end,
body = function()
notify('start')
expect_notification('textDocument/didOpen', {
textDocument = {
languageId = '',
text = table.concat({ 'testing', '123' }, '\n') .. '\n',
uri = 'file://',
version = 0,
},
})
expect_notification('textDocument/didChange', {
textDocument = {
uri = 'file://',
version = 3,
},
contentChanges = {
{ text = table.concat({ 'testing', 'boop' }, '\n') .. '\n' },
},
})
expect_notification('finish')
notify('finish')
end,
}
end
function tests.basic_check_buffer_open_and_change_noeol()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Full,
},
}
end,
body = function()
notify('start')
expect_notification('textDocument/didOpen', {
textDocument = {
languageId = '',
text = table.concat({ 'testing', '123' }, '\n'),
uri = 'file://',
version = 0,
},
})
expect_notification('textDocument/didChange', {
textDocument = {
uri = 'file://',
version = 3,
},
contentChanges = {
{ text = table.concat({ 'testing', 'boop' }, '\n') },
},
})
expect_notification('finish')
notify('finish')
end,
}
end
function tests.basic_check_buffer_open_and_change_multi()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Full,
},
}
end,
body = function()
notify('start')
expect_notification('textDocument/didOpen', {
textDocument = {
languageId = '',
text = table.concat({ 'testing', '123' }, '\n') .. '\n',
uri = 'file://',
version = 0,
},
})
expect_notification('textDocument/didChange', {
textDocument = {
uri = 'file://',
version = 3,
},
contentChanges = {
{ text = table.concat({ 'testing', '321' }, '\n') .. '\n' },
},
})
expect_notification('textDocument/didChange', {
textDocument = {
uri = 'file://',
version = 4,
},
contentChanges = {
{ text = table.concat({ 'testing', 'boop' }, '\n') .. '\n' },
},
})
expect_notification('finish')
notify('finish')
end,
}
end
function tests.basic_check_buffer_open_and_change_multi_and_close()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Full,
},
}
end,
body = function()
notify('start')
expect_notification('textDocument/didOpen', {
textDocument = {
languageId = '',
text = table.concat({ 'testing', '123' }, '\n') .. '\n',
uri = 'file://',
version = 0,
},
})
expect_notification('textDocument/didChange', {
textDocument = {
uri = 'file://',
version = 3,
},
contentChanges = {
{ text = table.concat({ 'testing', '321' }, '\n') .. '\n' },
},
})
expect_notification('textDocument/didChange', {
textDocument = {
uri = 'file://',
version = 4,
},
contentChanges = {
{ text = table.concat({ 'testing', 'boop' }, '\n') .. '\n' },
},
})
expect_notification('textDocument/didClose', {
textDocument = {
uri = 'file://',
},
})
expect_notification('finish')
notify('finish')
end,
}
end
function tests.basic_check_buffer_open_and_change_incremental()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = {
openClose = true,
change = protocol.TextDocumentSyncKind.Incremental,
willSave = true,
willSaveWaitUntil = true,
save = {
includeText = true,
},
},
},
}
end,
body = function()
notify('start')
expect_notification('textDocument/didOpen', {
textDocument = {
languageId = '',
text = table.concat({ 'testing', '123' }, '\n') .. '\n',
uri = 'file://',
version = 0,
},
})
expect_notification('textDocument/didChange', {
textDocument = {
uri = 'file://',
version = 3,
},
contentChanges = {
{
range = {
start = { line = 1, character = 3 },
['end'] = { line = 1, character = 3 },
},
rangeLength = 0,
text = 'boop',
},
},
})
expect_notification('finish')
notify('finish')
end,
}
end
function tests.basic_check_buffer_open_and_change_incremental_editing()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
textDocumentSync = protocol.TextDocumentSyncKind.Incremental,
},
}
end,
body = function()
notify('start')
expect_notification('textDocument/didOpen', {
textDocument = {
languageId = '',
text = table.concat({ 'testing', '123' }, '\n'),
uri = 'file://',
version = 0,
},
})
expect_notification('textDocument/didChange', {
textDocument = {
uri = 'file://',
version = 3,
},
contentChanges = {
{
range = {
start = { line = 0, character = 0 },
['end'] = { line = 1, character = 0 },
},
rangeLength = 4,
text = 'testing\n\n',
},
},
})
expect_notification('finish')
notify('finish')
end,
}
end
function tests.invalid_header()
io.stdout:write('Content-length: \r\n')
end
function tests.decode_nil()
skeleton {
on_init = function(_)
return { capabilities = {} }
end,
body = function()
notify('start')
notify('workspace/executeCommand', {
arguments = { 'EXTRACT_METHOD', { metadata = { field = vim.NIL } }, 3, 0, 6123, vim.NIL },
command = 'refactor.perform',
title = 'EXTRACT_METHOD',
})
notify('finish')
end,
}
end
function tests.code_action_with_resolve()
skeleton {
on_init = function()
return {
capabilities = {
codeActionProvider = {
resolveProvider = true,
},
},
}
end,
body = function()
notify('start')
local cmd = { title = 'Action 1' }
expect_request('textDocument/codeAction', function()
return nil, { cmd }
end)
expect_request('codeAction/resolve', function()
return nil,
{
title = 'Action 1',
command = {
title = 'Command 1',
command = 'dummy1',
},
}
end)
notify('shutdown')
end,
}
end
function tests.code_action_server_side_command()
skeleton({
on_init = function()
return {
capabilities = {
codeActionProvider = {
resolveProvider = false,
},
executeCommandProvider = {
commands = { 'dummy1' },
},
},
}
end,
body = function()
notify('start')
local cmd = {
title = 'Command 1',
command = 'dummy1',
}
expect_request('textDocument/codeAction', function()
return nil, { cmd }
end)
expect_request('workspace/executeCommand', function()
return nil, cmd
end)
notify('shutdown')
end,
})
end
function tests.code_action_filter()
skeleton {
on_init = function()
return {
capabilities = {
codeActionProvider = {
resolveProvider = false,
},
},
}
end,
body = function()
notify('start')
local action = {
title = 'Action 1',
command = 'command',
}
local preferred_action = {
title = 'Action 2',
isPreferred = true,
command = 'preferred_command',
}
local type_annotate_action = {
title = 'Action 3',
kind = 'type-annotate',
command = 'type_annotate_command',
}
local type_annotate_foo_action = {
title = 'Action 4',
kind = 'type-annotate.foo',
command = 'type_annotate_foo_command',
}
expect_request('textDocument/codeAction', function()
return nil, { action, preferred_action, type_annotate_action, type_annotate_foo_action }
end)
expect_request('textDocument/codeAction', function()
return nil, { action, preferred_action, type_annotate_action, type_annotate_foo_action }
end)
notify('shutdown')
end,
}
end
function tests.clientside_commands()
skeleton {
on_init = function()
return {
capabilities = {},
}
end,
body = function()
notify('start')
notify('shutdown')
end,
}
end
function tests.codelens_refresh_lock()
skeleton {
on_init = function()
return {
capabilities = {
codeLensProvider = { resolveProvider = true },
},
}
end,
body = function()
notify('start')
expect_request('textDocument/codeLens', function()
return { code = -32002, message = 'ServerNotInitialized' }, nil
end)
expect_request('textDocument/codeLens', function()
local lenses = {
{
range = {
start = { line = 0, character = 0 },
['end'] = { line = 0, character = 3 },
},
command = { title = 'Lens1', command = 'Dummy' },
},
}
return nil, lenses
end)
expect_request('textDocument/codeLens', function()
local lenses = {
{
range = {
start = { line = 0, character = 0 },
['end'] = { line = 0, character = 3 },
},
command = { title = 'Lens2', command = 'Dummy' },
},
}
return nil, lenses
end)
notify('shutdown')
end,
}
end
function tests.basic_formatting()
skeleton {
on_init = function()
return {
capabilities = {
documentFormattingProvider = true,
},
}
end,
body = function()
notify('start')
expect_request('textDocument/formatting', function()
return nil, {}
end)
notify('shutdown')
end,
}
end
function tests.range_formatting()
skeleton {
on_init = function()
return {
capabilities = {
documentFormattingProvider = true,
documentRangeFormattingProvider = true,
},
}
end,
body = function()
notify('start')
expect_request('textDocument/rangeFormatting', function()
return nil, {}
end)
notify('shutdown')
end,
}
end
function tests.ranges_formatting()
skeleton {
on_init = function()
return {
capabilities = {
documentFormattingProvider = true,
documentRangeFormattingProvider = {
rangesSupport = true,
},
},
}
end,
body = function()
notify('start')
expect_request('textDocument/rangesFormatting', function()
return nil, {}
end)
notify('shutdown')
end,
}
end
function tests.set_defaults_all_capabilities()
skeleton {
on_init = function(_)
return {
capabilities = {
definitionProvider = true,
completionProvider = true,
documentRangeFormattingProvider = true,
hoverProvider = true,
},
}
end,
body = function()
notify('test')
end,
}
end
function tests.inlay_hint()
skeleton {
on_init = function(params)
local expected_capabilities = protocol.make_client_capabilities()
assert_eq(params.capabilities, expected_capabilities)
return {
capabilities = {
inlayHintProvider = true,
},
}
end,
body = function()
notify('start')
expect_request('textDocument/inlayHint', function()
return nil, {}
end)
expect_notification('finish')
notify('finish')
end,
}
end
-- Tests will be indexed by test_name
local test_name = arg[1]
local timeout = arg[2]
assert(type(test_name) == 'string', 'test_name must be specified as first arg.')
local kill_timer = assert(vim.uv.new_timer())
kill_timer:start(timeout or 1e3, 0, function()
kill_timer:stop()
kill_timer:close()
log('ERROR', 'LSP', 'TIMEOUT')
io.stderr:write('TIMEOUT')
os.exit(100)
end)
local status, err = pcall(assert(tests[test_name], 'Test not found'))
kill_timer:stop()
kill_timer:close()
if not status then
log('ERROR', 'LSP', tostring(err))
io.stderr:write(err)
vim.cmd [[101cquit]]
end