From b9540098c2d00caf7aa66e75e3c04bec13353e8d Mon Sep 17 00:00:00 2001 From: danbopes Date: Mon, 26 Oct 2026 19:10:31 -0400 Subject: [PATCH] feat: support `---@field (read)` / `---@field (write)` asymmetric field types A field can now declare a different type for reading and for writing: ---@class LuaEntity ---@field (read) force LuaForce ---@field (write) force ForceID Reads, hover and narrowing use the read type; assignments and table literals are checked against the write type. A plain `---@field` still covers both directions, and a one-sided declaration falls back to its type for both. Refs #1298 Co-Authored-By: Claude Opus 5.5 --- changelog.md | 1 + locale/en-us/script.lua | 18 ++++- locale/es-419/script.lua | 2 +- locale/ja-jp/script.lua | 2 +- locale/pt-br/script.lua | 2 +- locale/zh-cn/script.lua | 2 +- locale/zh-tw/script.lua | 2 +- script/core/completion/completion.lua | 7 ++ .../core/diagnostics/assign-type-mismatch.lua | 2 +- .../core/diagnostics/duplicate-doc-field.lua | 5 ++ script/core/hover/label.lua | 13 +++- script/core/hover/table.lua | 12 +++ script/parser/guide.lua | 2 +- script/parser/luadoc.lua | 1 + script/vm/compiler.lua | 44 ++++++++++- script/vm/doc.lua | 40 ++++++++++ script/vm/type.lua | 15 ++++ test/diagnostics/assign-type-mismatch.lua | 75 +++++++++++++++++++ test/diagnostics/duplicate-doc-field.lua | 18 +++++ test/diagnostics/param-type-mismatch.lua | 15 ++++ test/hover/init.lua | 30 ++++++++ test/parser_test/ast/LuaDoc.lua | 58 ++++++++++++++ test/type_inference/common.lua | 43 +++++++++++ 23 files changed, 398 insertions(+), 11 deletions(-) diff --git a/changelog.md b/changelog.md index daefdb5ae..e82f5c6c8 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,7 @@ ## Unreleased +* `NEW` `---@field (read)` / `---@field (write)` declare different types for reading and writing a field; plain `---@field` still covers both [#1298](https://github.com/LuaLS/lua-language-server/issues/1298) ## 3.19.2 `2026-10-13` diff --git a/locale/en-us/script.lua b/locale/en-us/script.lua index f977d66f8..987af289d 100644 --- a/locale/en-us/script.lua +++ b/locale/en-us/script.lua @@ -912,8 +912,11 @@ Declare a field in a class/table. This allows you to provide more in-depth documentation for a table. As of `v3.6.0`, you can mark a field as `private`, `protected`, `public`, or `package`. +A field can declare a different type for reading and for writing with the +`(read)` and `(write)` attributes. A plain `---@field` is used for both. + ## Syntax -`---@field [scope] [description]` +`---@field [(read|write)] [scope] [description]` ## Usage ``` @@ -934,6 +937,19 @@ response = get("localhost") --Extension provided intellisense for the below assignment statusCode = response.status.code ``` + +### Different read and write types +``` +---@class Entity +---@field (read) force Force +---@field (write) force Force|string + +---@type Entity +local entity + +entity.force = "player" -- checked against `Force|string` +local force = entity.force -- `Force` +``` --- [View Wiki](https://luals.github.io/wiki/annotations#field) ]=] diff --git a/locale/es-419/script.lua b/locale/es-419/script.lua index 340a5232c..315db9d88 100644 --- a/locale/es-419/script.lua +++ b/locale/es-419/script.lua @@ -914,7 +914,7 @@ marcar un campo como privado (`private`), protegido (`protected`), público (`public`) o en-paquete (`package`). ## Sintaxis -`---@field [alcance] [descripción]` +`---@field [(read|write)] [alcance] [descripción]` ## Uso ``` diff --git a/locale/ja-jp/script.lua b/locale/ja-jp/script.lua index 10c6745ef..022b1796b 100644 --- a/locale/ja-jp/script.lua +++ b/locale/ja-jp/script.lua @@ -912,7 +912,7 @@ LUADOC_DESC_FIELD = クラス/テーブル内のフィールドを宣言します。`v3.6.0`以降では、フィールドを`private`、`protected`、`public`、または`package`として指定することができます。 ## 書き方 -`---@field [scope] [description]` +`---@field [(read|write)] [scope] [description]` ## 例 ``` diff --git a/locale/pt-br/script.lua b/locale/pt-br/script.lua index 6f7148fdd..dae23fbe8 100644 --- a/locale/pt-br/script.lua +++ b/locale/pt-br/script.lua @@ -913,7 +913,7 @@ para uma tabela. A partir da versão `v3.6.0`, você pode marcar um campo como ` `protected`, `public`, ou `package`. ## Sintaxe -`---@field [descrição]` +`---@field [(read|write)] [descrição]` ## Uso ``` diff --git a/locale/zh-cn/script.lua b/locale/zh-cn/script.lua index f5d45ec09..dbe27088c 100644 --- a/locale/zh-cn/script.lua +++ b/locale/zh-cn/script.lua @@ -913,7 +913,7 @@ LUADOC_DESC_FIELD = `protected`, `public` 或 `package`。 ## 语法 -`---@field [description]` +`---@field [(read|write)] [description]` ## 用法 ``` diff --git a/locale/zh-tw/script.lua b/locale/zh-tw/script.lua index 48c1c0af0..d9a164ab1 100644 --- a/locale/zh-tw/script.lua +++ b/locale/zh-tw/script.lua @@ -911,7 +911,7 @@ LUADOC_DESC_FIELD = 在類別/表中宣告一個欄位。 這使你可以為表提供更深入詳細的文件。 ## 語法 -`---@field [description]` +`---@field [(read|write)] [description]` ## 用法 ``` diff --git a/script/core/completion/completion.lua b/script/core/completion/completion.lua index a0b510c34..1cb194ccf 100644 --- a/script/core/completion/completion.lua +++ b/script/core/completion/completion.lua @@ -620,6 +620,13 @@ local function checkFieldOfRefs(refs, state, word, startPos, position, parent, o count = count + 1 goto CONTINUE end + if last + and vm.getFieldAccess(last) == 'write' + and src.type == 'doc.field' + and vm.getFieldAccess(src) ~= 'write' then + fields[name] = src + goto CONTINUE + end if vm.getDeprecated(src) then goto CONTINUE end diff --git a/script/core/diagnostics/assign-type-mismatch.lua b/script/core/diagnostics/assign-type-mismatch.lua index 6fa265533..785720861 100644 --- a/script/core/diagnostics/assign-type-mismatch.lua +++ b/script/core/diagnostics/assign-type-mismatch.lua @@ -96,7 +96,7 @@ return function (uri, callback) valueNode = valueNode:copy():setTruthy() end - local varNode = vm.compileNode(source) + local varNode = vm.compileWriteNode(source) local errs = {} if vm.canCastType(uri, varNode, valueNode, errs) then return diff --git a/script/core/diagnostics/duplicate-doc-field.lua b/script/core/diagnostics/duplicate-doc-field.lua index b39def96d..d7150f6ec 100644 --- a/script/core/diagnostics/duplicate-doc-field.lua +++ b/script/core/diagnostics/duplicate-doc-field.lua @@ -69,6 +69,11 @@ return function (uri, callback) if view ~= myView then goto CONTINUE end + local myAccess = vm.getFieldAccess(myField) + local access = vm.getFieldAccess(field) + if myAccess and access and myAccess ~= access then + goto CONTINUE + end callback { start = myField.field.start, finish = myField.field.finish, diff --git a/script/core/hover/label.lua b/script/core/hover/label.lua index 55c741aee..ce7d8158e 100644 --- a/script/core/hover/label.lua +++ b/script/core/hover/label.lua @@ -43,9 +43,10 @@ end ---@async ---@param level integer -local function asValue(source, title, level) +---@param node? vm.node +local function asValue(source, title, level, node) local name = buildName(source, false) or '' - local ifr = vm.getInfer(source) + local ifr = vm.getInfer(node or source) local type = ifr:view(guide.getUri(source)) local literal = ifr:viewLiterals() local cont, maxLevel = buildTable(source, level) @@ -137,6 +138,14 @@ local function asField(source, level) if isGlobalField(source) then return asGlobal(source, level) end + local setter = source + if source.type == 'field' or source.type == 'method' then + setter = source.parent + end + local writeNode = vm.compileWriteNode(setter) + if writeNode ~= vm.compileNode(setter) then + return asValue(source, '(field)', level, writeNode) + end return asValue(source, '(field)', level) end diff --git a/script/core/hover/table.lua b/script/core/hover/table.lua index fda25f91b..004cf80d5 100644 --- a/script/core/hover/table.lua +++ b/script/core/hover/table.lua @@ -141,11 +141,23 @@ end local function getNodeMap(uri, fields, keyMap) ---@type table local nodeMap = {} + local readableKeys = {} + for _, field in ipairs(fields) do + if field.type == 'doc.field' and vm.getFieldAccess(field) ~= 'write' then + local key = vm.viewKey(field, uri) + if key then + readableKeys[key] = true + end + end + end for _, field in ipairs(fields) do local key = vm.viewKey(field, uri) if not key or not keyMap[key] then goto CONTINUE end + if readableKeys[key] and vm.getFieldAccess(field) == 'write' then + goto CONTINUE + end await.delay() local node = vm.compileNode(field) if nodeMap[key] then diff --git a/script/parser/guide.lua b/script/parser/guide.lua index e3c7660b3..8827ad234 100644 --- a/script/parser/guide.lua +++ b/script/parser/guide.lua @@ -177,7 +177,7 @@ local childMap = { ['doc.enum'] = {'enum', 'extends', 'comment', 'docAttr'}, ['doc.param'] = {'param', 'extends', 'comment'}, ['doc.return'] = {'#returns', 'comment'}, - ['doc.field'] = {'field', 'extends', 'comment'}, + ['doc.field'] = {'docAttr', 'field', 'extends', 'comment'}, ['doc.generic'] = {'#generics', 'comment'}, ['doc.generic.object'] = {'generic', 'extends', 'comment'}, ['doc.vararg'] = {'vararg', 'comment'}, diff --git a/script/parser/luadoc.lua b/script/parser/luadoc.lua index 6d59b09c3..8ca97185f 100644 --- a/script/parser/luadoc.lua +++ b/script/parser/luadoc.lua @@ -1218,6 +1218,7 @@ local docSwitch = util.switch() local result = { type = 'doc.field', } + result.docAttr = parseDocAttr(result) try(function () local tp, value = nextToken() if tp == 'name' then diff --git a/script/vm/compiler.lua b/script/vm/compiler.lua index 20ab3ac41..5be7b3fb2 100644 --- a/script/vm/compiler.lua +++ b/script/vm/compiler.lua @@ -1054,9 +1054,14 @@ end ---@param source parser.object | vm.variable ---@param key string|vm.global|vm.ANY|vm.ANYDOC ---@param pushResult fun(source: parser.object) -function vm.compileByParentNode(source, key, pushResult) +---@param access? vm.field.access # defaults to `read` +function vm.compileByParentNode(source, key, pushResult, access) local docedResults, commonResults = vm.getNodesOfParentNode(source, key) + if key ~= vm.ANY and key ~= vm.ANYDOC then + docedResults = vm.filterFieldsByAccess(docedResults, access or 'read') + end + if #docedResults > 0 then for _, res in ipairs(docedResults) do pushResult(res) @@ -2805,3 +2810,40 @@ function vm.compileNode(source) ---@cast node -? return node end + +local writeSourceTypes = util.arrayToHash { + 'setfield', + 'setmethod', + 'setindex', + 'tablefield', + 'tableindex', +} + +-- The type a value must have to be assigned to `source`. +-- Differs from `vm.compileNode(source)` only when the field is declared +-- with both `---@field (read)` and `---@field (write)`; `vm.compileNode` +-- keeps the read type so narrowing after an assignment stays correct. +---@param source parser.object +---@return vm.node +function vm.compileWriteNode(source) + local readNode = vm.compileNode(source) + if not writeSourceTypes[source.type] then + return readNode + end + if source.bindDocs and vm.bindDocs(source) then + return readNode + end + local key = guide.getKeyName(source) + if type(key) ~= 'string' then + return readNode + end + local writeNode + vm.compileByParentNode(source.node, key, function (src) + if vm.getFieldAccess(src) ~= 'write' then + return + end + writeNode = writeNode or vm.createNode() + writeNode:merge(vm.compileNode(src)) + end, 'write') + return writeNode or readNode +end diff --git a/script/vm/doc.lua b/script/vm/doc.lua index a2689439b..1ebd2b55d 100644 --- a/script/vm/doc.lua +++ b/script/vm/doc.lua @@ -524,3 +524,43 @@ function vm.docHasAttr(doc, key) end return false end + +---@alias vm.field.access 'read' | 'write' + +---@param doc parser.object +---@return vm.field.access? +function vm.getFieldAccess(doc) + if doc.type ~= 'doc.field' then + return nil + end + local read = vm.docHasAttr(doc, 'read') + local write = vm.docHasAttr(doc, 'write') + if read == write then + return nil + end + return read and 'read' or 'write' +end + +-- `---@field (read)` and `---@field (write)` only narrow which declaration +-- applies when the other direction is also declared; a one-sided field is +-- used for both reads and writes, like a plain `---@field`. +---@param results parser.object[] +---@param access vm.field.access +---@return parser.object[] +function vm.filterFieldsByAccess(results, access) + local filtered = {} + local hasDocField + for _, res in ipairs(results) do + local resAccess = vm.getFieldAccess(res) + if resAccess == nil or resAccess == access then + filtered[#filtered+1] = res + if res.type == 'doc.field' then + hasDocField = true + end + end + end + if not hasDocField then + return results + end + return filtered +end diff --git a/script/vm/type.lua b/script/vm/type.lua index 524eb4cc2..a8a78d221 100644 --- a/script/vm/type.lua +++ b/script/vm/type.lua @@ -308,6 +308,17 @@ local function checkTableShape(parent, child, uri, mark, errs) end end + -- a table literal writes its fields, so `(write)` declarations win + local writeKeys = {} + for _, field in ipairs(def.fields) do + if vm.getFieldAccess(field) == 'write' then + local key = vm.getKeyName(field) + if key then + writeKeys[key] = true + end + end + end + for _, field in ipairs(def.fields) do local key = vm.getKeyName(field) if not key then @@ -320,6 +331,9 @@ local function checkTableShape(parent, child, uri, mark, errs) if not key then goto continue end + if writeKeys[key] and vm.getFieldAccess(field) == 'read' then + goto nextField + end local ok local nodeField = vm.compileNode(field) @@ -341,6 +355,7 @@ local function checkTableShape(parent, child, uri, mark, errs) end failedCheck = true end + ::nextField:: end ::continue:: end diff --git a/test/diagnostics/assign-type-mismatch.lua b/test/diagnostics/assign-type-mismatch.lua index dc55a7dac..c81c62d5f 100644 --- a/test/diagnostics/assign-type-mismatch.lua +++ b/test/diagnostics/assign-type-mismatch.lua @@ -479,3 +479,78 @@ t.a = 1 t.a = 2 return t ]] + +TEST [[ +---@class Force + +---@class Entity +---@field (read) force Force +---@field (write) force Force|string + +---@type Entity +local entity +entity.force = 'player' +]] + +TEST [[ +---@class Force + +---@class Entity +---@field (read) force Force +---@field (write) force Force|string + +---@type Entity +local entity + = true +]] + +TEST [[ +---@class Force + +---@class Entity +---@field (write) force Force|string +---@field (read) force Force + +---@type Entity +local entity = { force = 'player' } +]] + +TEST [[ +---@class Force + +---@class Entity +---@field (read) force Force +---@field (write) force Force|string + +---@type Entity +local entity = { = true } +]] + +TEST [[ +---@class Counter +---@field (read) n integer +---@field (write) n number + +---@type Counter +local c +c.n = c.n + 0.5 +]] + +TEST [[ +---@class Entity +---@field (read) name string + +---@type Entity +local entity + = 1 +]] + +TEST [[ +---@class Entity +---@field (write) name string + +---@type Entity +local entity +entity.name = 'x' + = 1 +]] diff --git a/test/diagnostics/duplicate-doc-field.lua b/test/diagnostics/duplicate-doc-field.lua index 925e43896..58096ddc6 100644 --- a/test/diagnostics/duplicate-doc-field.lua +++ b/test/diagnostics/duplicate-doc-field.lua @@ -36,3 +36,21 @@ TEST [[ ---@field [] A ---@field [] true ]] + +TEST [[ +---@class A +---@field (read) x integer +---@field (write) x number +]] + +TEST [[ +---@class A +---@field (read) integer +---@field (read) number +]] + +TEST [[ +---@class A +---@field integer +---@field (read) number +]] diff --git a/test/diagnostics/param-type-mismatch.lua b/test/diagnostics/param-type-mismatch.lua index fce7dca39..baa4a42ab 100644 --- a/test/diagnostics/param-type-mismatch.lua +++ b/test/diagnostics/param-type-mismatch.lua @@ -414,3 +414,18 @@ f(child) ]] config.set(nil, 'Lua.type.checkTableShape', false) + +TEST [[ +---@class Force + +---@class Entity +---@field (read) force Force +---@field (write) force Force|string + +---@param f Force +local function useForce(f) end + +---@type Entity +local entity +useForce(entity.force) +]] diff --git a/test/hover/init.lua b/test/hover/init.lua index dbe245ad4..87ebd3e42 100644 --- a/test/hover/init.lua +++ b/test/hover/init.lua @@ -2553,3 +2553,33 @@ global (global) any ]] config.set(nil, 'Lua.runtime.version', nil) + +TEST [[ +---@class Force + +---@class Entity +---@field (read) force Force +---@field (write) force Force|string + +---@type Entity +local entity +local f = entity. +]] +[[ +(field) Entity.force: Force +]] + +TEST [[ +---@class Force + +---@class Entity +---@field (read) force Force +---@field (write) force Force|string + +---@type Entity +local entity +entity. = 'player' +]] +[[ +(field) Entity.force: string|Force +]] diff --git a/test/parser_test/ast/LuaDoc.lua b/test/parser_test/ast/LuaDoc.lua index 9410f2270..00cff7989 100644 --- a/test/parser_test/ast/LuaDoc.lua +++ b/test/parser_test/ast/LuaDoc.lua @@ -405,6 +405,64 @@ LuaDoc [[ }, } +LuaDoc [[ +---@field (read) open function +]] +{ + type = "doc", + start = 0, + finish = 10000, + parent = "", + [1] = { + type = "doc.field", + start = 17, + finish = 30, + range = 30, + parent = "", + docAttr = { + type = "doc.attr", + start = 10, + finish = 16, + parent = "", + names = { + [1] = { + type = "doc.attr.name", + start = 11, + finish = 15, + parent = "", + [1] = "read", + }, + }, + }, + field = { + type = "doc.field.name", + start = 17, + finish = 21, + parent = "", + [1] = "open", + }, + bindComments = { + }, + extends = { + type = "doc.type", + start = 22, + finish = 30, + parent = "", + firstFinish = 30, + types = { + [1] = { + type = "doc.type.name", + start = 22, + finish = 30, + parent = "", + [1] = "function", + }, + }, + }, + originalComment = "", + }, +} + LuaDoc [[ ---@field mode "async" | "sync" ]] diff --git a/test/type_inference/common.lua b/test/type_inference/common.lua index 2fe6417d2..34916be0f 100644 --- a/test/type_inference/common.lua +++ b/test/type_inference/common.lua @@ -5278,3 +5278,46 @@ local myClass local = myClass:pass(42) ]] + +TEST 'Force' [[ +---@class Force + +---@class Entity +---@field (read) force Force +---@field (write) force Force|string + +---@type Entity +local entity +local = entity.force +]] + +TEST 'Force' [[ +---@class Force + +---@class Entity +---@field (write) force Force|string +---@field (read) force Force + +---@type Entity +local entity +entity.force = 'player' +local = entity.force +]] + +TEST 'string' [[ +---@class Entity +---@field (write) name string + +---@type Entity +local entity +local = entity.name +]] + +TEST 'string' [[ +---@class Entity +---@field (read) name string + +---@type Entity +local entity +local = entity.name +]]