diff --git a/changelog.md b/changelog.md index 9f42bd85f..61035b20e 100644 --- a/changelog.md +++ b/changelog.md @@ -3,6 +3,7 @@ ## Unreleased * `FIX` `need-check-nil` diagnostic is no longer reported on safe navigation access (e.g. `x?.field`, `f?.()`, `t?.[key]`), since the optional access itself already handles the nil check. Note that a non-safe access chained after a safe one (e.g. `x.upper()?.field`) still reports, because the safe access only protects its own result. +* `FIX` Prevent hover and enum completion responses from containing invalid UTF-8 when escaped string literals decode to arbitrary bytes (e.g. `"\xC2"`). ## 3.19.1 `2026-08-14` diff --git a/script/core/completion/completion.lua b/script/core/completion/completion.lua index 8176c8840..a0b510c34 100644 --- a/script/core/completion/completion.lua +++ b/script/core/completion/completion.lua @@ -1277,7 +1277,7 @@ local function insertDocEnumKey(doc, enums) goto CONTINUE end enums[#enums+1] = { - label = ('%q'):format(key), + label = util.viewLiteral(key), kind = define.CompletionItemKind.EnumMember, id = stack(field, function (newField) ---@async return { diff --git a/script/core/hover/description.lua b/script/core/hover/description.lua index 437cf6a88..083985e27 100644 --- a/script/core/hover/description.lua +++ b/script/core/hover/description.lua @@ -64,10 +64,11 @@ local function asStringView(source, literal) if config.get(guide.getUri(source), 'Lua.hover.viewString') and (source[2] == '"' or source[2] == "'") and rawLen > #literal then - local view = literal + local view = util.escapeInvalidUtf8(literal) local max = config.get(guide.getUri(source), 'Lua.hover.viewStringMax') if #view > max then - view = view:sub(1, max) .. '...' + local nextCharacter = utf8.offset(view, 0, max + 1) + view = view:sub(1, nextCharacter - 1) .. '...' end local md = markdown() md:add('txt', view) @@ -486,7 +487,7 @@ local function tryDocEnum(source) if not key then goto CONTINUE end - keys[#keys+1] = ('%q'):format(key) + keys[#keys+1] = util.viewLiteral(key) ::CONTINUE:: end end @@ -507,7 +508,8 @@ local function tryDocEnum(source) end if field.value.type == 'integer' or field.value.type == 'string' then - md:add('lua', (' %s: %s = %q,'):format(key, field.value.type, field.value[1])) + local value = util.viewLiteral(field.value[1]) + md:add('lua', (' %s: %s = %s,'):format(key, field.value.type, value)) end if field.value.type == 'binary' or field.value.type == 'unary' then diff --git a/script/utility.lua b/script/utility.lua index 74bf05c80..352583c18 100644 --- a/script/utility.lua +++ b/script/utility.lua @@ -469,7 +469,10 @@ local esc = { ['\n'] = '\\\n', } -local function escapeInvalidUtf8(str) +function m.escapeInvalidUtf8(str) + if utf8Len(str) then + return str + end local result = {} local start = 1 while true do @@ -486,9 +489,7 @@ local function escapeInvalidUtf8(str) end function m.viewString(str, quo) - if not utf8Len(str) then - str = escapeInvalidUtf8(str) - end + str = m.escapeInvalidUtf8(str) if not quo then if str:find('[\r\n]') then quo = '[[' diff --git a/test/completion/common.lua b/test/completion/common.lua index 32cc68481..70fecf338 100644 --- a/test/completion/common.lua +++ b/test/completion/common.lua @@ -3879,6 +3879,24 @@ f() }, } +TEST [[ +---@enum(key) Enum +local t = { + ["\xC2"] = 1, +} + +---@param p Enum +local function f(p) end + +f() +]] +{ + { + label = '"\\194"', + kind = define.CompletionItemKind.EnumMember, + }, +} + TEST [[ ---@class optional ---@field enum enum diff --git a/test/hover/init.lua b/test/hover/init.lua index 5d19615a7..dbe245ad4 100644 --- a/test/hover/init.lua +++ b/test/hover/init.lua @@ -2,6 +2,7 @@ local core = require 'core.hover' local files = require 'files' local catch = require 'catch' local config = require 'config' +local json = require 'json' rawset(_G, 'TEST', true) @@ -12,8 +13,18 @@ function TEST(script) files.setText(TESTURI, newScript) local hover = core.byUri(TESTURI, catched['?'][1][1], 1) assert(hover) + local value = hover:string():gsub('\r\n', '\n') + assert(utf8.len(value)) + assert(utf8.len(json.encode { + result = { + contents = { + kind = 'markdown', + value = value, + }, + }, + })) expect = expect:gsub('^[\r\n]*(.-)[\r\n]*$', '%1'):gsub('\r\n', '\n') - local label = hover:string():gsub('\r\n', '\n'):match('```lua[\r\n]*(.-)[\r\n]*```') + local label = value:match('```lua[\r\n]*(.-)[\r\n]*```') assert(expect == label) files.remove(TESTURI) end @@ -373,6 +384,11 @@ local s = ]] [[9 个字节,5 个字符]] +TEST [[ +local fail = +]] +[[1 个字节]] + TEST [[ local n = ]] @@ -2127,6 +2143,22 @@ local m = { (enum) A ]] +TEST [[ +---@enum +local Broken = { + A = "\xC2", +} +]] +[[(enum) Broken]] + +TEST [[ +---@enum(key) +local Key = { + ["\xC2"] = 1, +} +]] +[[(enum) Key]] + TEST [[ local = 1 << 2 ]]