Skip to content

Commit 14923f7

Browse files
committed
fix(lsp): refactor text line splitting
1 parent 5d653a1 commit 14923f7

File tree

6 files changed

+198
-18
lines changed

6 files changed

+198
-18
lines changed

runtime/lua/vim/lsp.lua

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,13 @@ local format_line_ending = {
115115
["mac"] = '\r',
116116
}
117117

118+
---@private
119+
---@param bufnr (number)
120+
---@returns (string)
121+
local function buf_get_line_ending(bufnr)
122+
return format_line_ending[nvim_buf_get_option(bufnr, 'fileformat')] or '\n'
123+
end
124+
118125
local client_index = 0
119126
---@private
120127
--- Returns a new, unused client id.
@@ -278,9 +285,10 @@ end
278285
---@param bufnr (number) Buffer handle, or 0 for current.
279286
---@returns Buffer text as string.
280287
local function buf_get_full_text(bufnr)
281-
local text = table.concat(nvim_buf_get_lines(bufnr, 0, -1, true), '\n')
288+
local line_ending = buf_get_line_ending(bufnr)
289+
local text = table.concat(nvim_buf_get_lines(bufnr, 0, -1, true), line_ending)
282290
if nvim_buf_get_option(bufnr, 'eol') then
283-
text = text .. '\n'
291+
text = text .. line_ending
284292
end
285293
return text
286294
end
@@ -362,9 +370,9 @@ do
362370
local incremental_changes = function(client)
363371
local cached_buffers = state_by_client[client.id].buffers
364372
local curr_lines = nvim_buf_get_lines(bufnr, 0, -1, true)
365-
local line_ending = format_line_ending[vim.api.nvim_buf_get_option(0, 'fileformat')]
373+
local line_ending = buf_get_line_ending(bufnr)
366374
local incremental_change = sync.compute_diff(
367-
cached_buffers[bufnr], curr_lines, firstline, lastline, new_lastline, client.offset_encoding or 'utf-16', line_ending or '\n')
375+
cached_buffers[bufnr], curr_lines, firstline, lastline, new_lastline, client.offset_encoding or 'utf-16', line_ending)
368376
cached_buffers[bufnr] = curr_lines
369377
return incremental_change
370378
end

runtime/lua/vim/lsp/buf.lua

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -546,8 +546,7 @@ local function on_code_action_results(results, ctx)
546546
prompt = 'Code actions:',
547547
kind = 'codeaction',
548548
format_item = function(action_tuple)
549-
local title = action_tuple[2].title:gsub('\r\n', '\\r\\n')
550-
return title:gsub('\n', '\\n')
549+
return table.concat(util.split_lines(action_tuple[2].title), '\\n')
551550
end,
552551
}, on_user_choice)
553552
end

runtime/lua/vim/lsp/diagnostic.lua

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,10 @@ local function diagnostic_lsp_to_vim(diagnostics, bufnr, client_id)
102102
end_lnum = _end.line,
103103
end_col = line_byte_from_position(buf_lines, _end.line, _end.character, offset_encoding),
104104
severity = severity_lsp_to_vim(diagnostic.severity),
105-
message = diagnostic.message,
105+
message = table.concat(vim.lsp.util.split_lines(diagnostic.message), '\n'),
106106
source = diagnostic.source,
107107
user_data = {
108+
lsp_original_message = diagnostic.message,
108109
lsp = {
109110
code = diagnostic.code,
110111
codeDescription = diagnostic.codeDescription,
@@ -132,7 +133,7 @@ local function diagnostic_vim_to_lsp(diagnostics)
132133
},
133134
},
134135
severity = severity_vim_to_lsp(diagnostic.severity),
135-
message = diagnostic.message,
136+
message = (diagnostic.user_data and diagnostic.user_data.lsp_original_message) or diagnostic.message,
136137
source = diagnostic.source,
137138
}, diagnostic.user_data and (diagnostic.user_data.lsp or {}) or {})
138139
end, diagnostics)

runtime/lua/vim/lsp/handlers.lua

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,7 @@ M['window/showMessageRequest'] = function(_, result)
8282
print(result.message)
8383
local option_strings = {result.message, "\nRequest Actions:"}
8484
for i, action in ipairs(actions) do
85-
local title = action.title:gsub('\r\n', '\\r\\n')
86-
title = title:gsub('\n', '\\n')
85+
local title = table.concat(util.split_lines(action.title), '\\n')
8786
table.insert(option_strings, string.format("%d. %s", i, title))
8887
end
8988

runtime/lua/vim/lsp/util.lua

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,95 @@ local function get_border_size(opts)
8585
return { height = height, width = width }
8686
end
8787

88-
---@private
89-
local function split_lines(value)
90-
return split(value, '\n', true)
88+
--- Splits the given text into lines according to LSP's definition of text
89+
--- lines. Quoting the specification: "To ensure that both client and server
90+
--- split the string into the same line representation the protocol specifies
91+
--- the following end-of-line sequences: '\n', '\r\n' and '\r'."
92+
---
93+
---@see |vim.lsp.util.split_lines_iter()|
94+
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocuments
95+
function M.split_lines(text, opts)
96+
local lines = {}
97+
local i = 1
98+
for _, line in M.split_lines_iter(text, opts) do
99+
lines[i] = line
100+
i = i + 1
101+
end
102+
return lines
103+
end
104+
105+
--- Same as |vim.lsp.util.split_lines()|, but returns an iterator instead of a
106+
--- list of lines.
107+
function M.split_lines_iter(text, opts)
108+
validate {
109+
text = { text, 's' };
110+
opts = { opts, 't', true };
111+
}
112+
opts = opts or {}
113+
validate {
114+
['opts.keep_line_endings'] = { opts.keep_line_endings, 'b', true };
115+
['opts.keep_final_eol'] = { opts.keep_final_eol, 'b', true };
116+
}
117+
local keep_line_endings = opts.keep_line_endings
118+
local keep_final_eol = opts.keep_final_eol
119+
120+
local string_find = string.find
121+
local string_sub = string.sub
122+
local math_min = math.min
123+
124+
-- Most text will be LF-separated anyway, so needless \r lookups which would
125+
-- have to scan the whole string can be skipped. There is no such flag for \n
126+
-- because in the CRLF case the \n lookup still must be performed, and let's
127+
-- face it - nobody uses CR linebreaks anymore.
128+
local no_carrige_returns_left = false
129+
130+
-- NOTE: string.find is implemented using memchr in LuaJIT, which will most
131+
-- likely be faster than running our own loops.
132+
local function find_curr_line_ending(start_idx)
133+
local lf_idx = string_find(text, '\n', start_idx, true)
134+
local cr_idx = nil
135+
if not no_carrige_returns_left then
136+
cr_idx = string_find(text, '\r', start_idx, true)
137+
no_carrige_returns_left = not cr_idx
138+
end
139+
if lf_idx and cr_idx then
140+
if cr_idx + 1 == lf_idx then
141+
return cr_idx, 2
142+
else
143+
return math_min(lf_idx, cr_idx), 1
144+
end
145+
else
146+
return lf_idx or cr_idx, 1
147+
end
148+
end
149+
150+
local line_start_idx = 1
151+
local done = false
152+
return function()
153+
if done then
154+
return
155+
end
156+
157+
local line_ending_idx, line_ending_len = find_curr_line_ending(line_start_idx)
158+
if not line_ending_idx then
159+
done = true
160+
local line = string_sub(text, line_start_idx)
161+
return line_start_idx, line
162+
end
163+
164+
local line_end_idx = line_ending_idx - 1
165+
if keep_line_endings then
166+
line_end_idx = line_end_idx + line_ending_len
167+
end
168+
local line = string_sub(text, line_start_idx, line_end_idx)
169+
170+
local prev_line_start_idx = line_start_idx
171+
line_start_idx = line_ending_idx + line_ending_len
172+
if line_start_idx > #text and not keep_final_eol then
173+
done = true
174+
end
175+
return prev_line_start_idx, line
176+
end
91177
end
92178

93179
--- Replaces text in a range with new text.
@@ -324,7 +410,7 @@ function M.apply_text_edits(text_edits, bufnr)
324410
start_col = get_line_byte_from_position(bufnr, text_edit.range.start),
325411
end_row = text_edit.range['end'].line,
326412
end_col = get_line_byte_from_position(bufnr, text_edit.range['end']),
327-
text = vim.split(text_edit.newText, '\n', true),
413+
text = M.split_lines(text_edit.newText, { keep_final_eol = true }),
328414
}
329415
vim.api.nvim_buf_set_text(bufnr, e.start_row, e.start_col, e.end_row, e.end_col, e.text)
330416

@@ -647,7 +733,7 @@ function M.convert_input_to_markdown_lines(input, contents)
647733
contents = contents or {}
648734
-- MarkedString variation 1
649735
if type(input) == 'string' then
650-
list_extend(contents, split_lines(input))
736+
list_extend(contents, M.split_lines(input))
651737
else
652738
assert(type(input) == 'table', "Expected a table for Hover.contents")
653739
-- MarkupContent
@@ -665,13 +751,13 @@ function M.convert_input_to_markdown_lines(input, contents)
665751
end
666752

667753
-- assert(type(value) == 'string')
668-
list_extend(contents, split_lines(value))
754+
list_extend(contents, M.split_lines(value))
669755
-- MarkupString variation 2
670756
elseif input.language then
671757
-- Some servers send input.value as empty, so let's ignore this :(
672758
-- assert(type(input.value) == 'string')
673759
table.insert(contents, "```"..input.language)
674-
list_extend(contents, split_lines(input.value or ''))
760+
list_extend(contents, M.split_lines(input.value or ''))
675761
table.insert(contents, "```")
676762
-- By deduction, this must be MarkedString[]
677763
else
@@ -718,7 +804,7 @@ function M.convert_signature_help_to_markdown_lines(signature_help, ft, triggers
718804
-- wrap inside a code block so stylize_markdown can render it properly
719805
label = ("```%s\n%s\n```"):format(ft, label)
720806
end
721-
vim.list_extend(contents, vim.split(label, '\n', true))
807+
vim.list_extend(contents, M.split_lines(label))
722808
if signature.documentation then
723809
M.convert_input_to_markdown_lines(signature.documentation, contents)
724810
end

test/functional/plugin/lsp_spec.lua

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2578,4 +2578,91 @@ describe('LSP', function()
25782578
}
25792579
end)
25802580
end)
2581+
2582+
describe('lsp.util.split_lines', function()
2583+
local function test_split_lines(expected_lines, ...)
2584+
eq(expected_lines, exec_lua('return vim.lsp.util.split_lines(...)', ...))
2585+
end
2586+
2587+
it('handles empty strings', function()
2588+
test_split_lines({''}, '')
2589+
end)
2590+
2591+
it('handles strings without line ending characters', function()
2592+
test_split_lines({'hi!'}, 'hi!')
2593+
end)
2594+
2595+
it('handles strings without a final EOL', function()
2596+
test_split_lines({'abcdef', 'ghijkl'}, 'abcdef\nghijkl')
2597+
test_split_lines({'a', 'b', 'd'}, 'a\r\nb\nd')
2598+
end)
2599+
2600+
local test_text = table.concat({
2601+
'#include <stdio.h>\n',
2602+
'int main() {\r\n',
2603+
' printf(\r"привет мир");\r\n\r', -- Let's throw in some multibyte because why not
2604+
' return 0;\n\r',
2605+
'}\n',
2606+
})
2607+
2608+
it('handles text with mixed newlines', function()
2609+
test_split_lines({
2610+
'#include <stdio.h>',
2611+
'int main() {',
2612+
' printf(',
2613+
'"привет мир");',
2614+
'',
2615+
' return 0;',
2616+
'',
2617+
'}',
2618+
}, test_text)
2619+
end)
2620+
2621+
it('handles keep_line_endings', function()
2622+
test_split_lines({
2623+
'#include <stdio.h>\n',
2624+
'int main() {\r\n',
2625+
' printf(\r',
2626+
'"привет мир");\r\n',
2627+
'\r',
2628+
' return 0;\n',
2629+
'\r',
2630+
'}\n',
2631+
}, test_text, { keep_line_endings = true })
2632+
end)
2633+
2634+
it('handles keep_final_eol', function()
2635+
test_split_lines({ 'абв', 'где', '' }, 'абв\nгде\n', { keep_final_eol = true })
2636+
test_split_lines({ 'абв', 'где' }, 'абв\nгде', { keep_final_eol = true })
2637+
test_split_lines({ 'абв\n', 'где\n', '' }, 'абв\nгде\n', { keep_final_eol = true, keep_line_endings = true })
2638+
test_split_lines({ 'абв\n', 'где' }, 'абв\nгде', { keep_final_eol = true, keep_line_endings = true })
2639+
end)
2640+
2641+
it('handles a final CRLF', function()
2642+
test_split_lines({ 'test' }, 'test\r\n')
2643+
end)
2644+
2645+
it('handles the no_carrige_returns_left optimization correctly', function()
2646+
test_split_lines({ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h' }, 'a\r\nb\r\nc\rd\ne\nf\ng\nh\n')
2647+
end)
2648+
2649+
it('the iterator returns correct line start indexes', function()
2650+
eq({
2651+
{ 1, '#include <stdio.h>' },
2652+
{ 20, 'int main() {' },
2653+
{ 34, ' printf(' },
2654+
{ 44, '"привет мир");' },
2655+
{ 69, '' },
2656+
{ 70, ' return 0;' },
2657+
{ 82, '' },
2658+
{ 83, '}' },
2659+
}, exec_lua([[
2660+
local results = {}
2661+
for start_idx, line in vim.lsp.util.split_lines_iter(...) do
2662+
table.insert(results, { start_idx, line })
2663+
end
2664+
return results
2665+
]], test_text))
2666+
end)
2667+
end)
25812668
end)

0 commit comments

Comments
 (0)