From 42a0b92d04b35a05900672d915980c811b37eac7 Mon Sep 17 00:00:00 2001 From: Wilfred Hughes Date: Thu, 6 Aug 2026 13:34:46 +0100 Subject: [PATCH] fix: invalid UTF-8 JSON due to escaped bytes in string literals Lua strings are arbitrary bytes, and the Lua parser converts string literals to their values. For example, '\xff' is byte 255, which isn't a valid UTF-8 byte by itself. As a result, we'd end up sending arbitrary bytes over LSP. LSP requires UTF-8 JSON, so the LSP client (e.g. VS Code) wouldn't be able to process the response from lua-language-server. Instead, ensure that we escape byte values that aren't valid UTF-8, so they don't break our JSON output. In our '\xff' example we can send it in an escaped form: \255 I noticed this when trying to use lua-language-server on its own source code. parser/guide.lua which has the literal '[^%w_\x80-\xff]' which triggered this bug. Fix the escaping, and add tests. --- changelog.md | 1 + script/utility.lua | 19 +++++++++++++++++++ test/other/init.lua | 1 + test/other/view-string.lua | 15 +++++++++++++++ 4 files changed, 36 insertions(+) create mode 100644 test/other/view-string.lua diff --git a/changelog.md b/changelog.md index c9375a4cd..ffd57db7b 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,7 @@ * `FIX` Deduplicate documentation bindings for parameters * `FIX` Correct `math.type` meta return annotation to use `nil` instead of the string literal `'nil'` * `FIX` Fix initial `nameStyle.config` not getting loaded in the appropriate workspace. +* `FIX` Fix invalid LSP responses in files containing string literals with escaped bytes. ## 3.18.2 * `CHG` `duplicate-set-field` diagnostic now supports linked suppression: when any occurrence of a duplicate field is suppressed with `---@diagnostic disable` or `---@diagnostic disable-next-line`, all warnings for that field name will be suppressed diff --git a/script/utility.lua b/script/utility.lua index 0b4b0ab62..74bf05c80 100644 --- a/script/utility.lua +++ b/script/utility.lua @@ -469,7 +469,26 @@ local esc = { ['\n'] = '\\\n', } +local function escapeInvalidUtf8(str) + local result = {} + local start = 1 + while true do + local _, invalid = utf8Len(str, start) + if not invalid then + result[#result+1] = str:sub(start) + break + end + result[#result+1] = str:sub(start, invalid - 1) + result[#result+1] = ('\\%03d'):format(stringByte(str, invalid)) + start = invalid + 1 + end + return tableConcat(result) +end + function m.viewString(str, quo) + if not utf8Len(str) then + str = escapeInvalidUtf8(str) + end if not quo then if str:find('[\r\n]') then quo = '[[' diff --git a/test/other/init.lua b/test/other/init.lua index fbe9d923b..51b085164 100644 --- a/test/other/init.lua +++ b/test/other/init.lua @@ -1 +1,2 @@ --require 'other.filewatch' +require 'other.view-string' diff --git a/test/other/view-string.lua b/test/other/view-string.lua new file mode 100644 index 000000000..86b2e3373 --- /dev/null +++ b/test/other/view-string.lua @@ -0,0 +1,15 @@ +local util = require 'utility' + +local function assertView(value, expected) + local literal = util.viewString(value) + assert(utf8.len(literal)) + assert(literal == expected, ('expected %q, got %q'):format(expected, literal)) +end + +assertView('plain text', '"plain text"') +assertView('é中文', '"é中文"') +assertView('\x80', '"\\128"') +assertView('\xff', '"\\255"') +assertView('\xc2A', '"\\194A"') +assertView('\xe2\x82', '"\\226\\130"') +assertView('[^%w_\x80-\xff]', '"[^%w_\\128-\\255]"')