diff --git a/changelog.md b/changelog.md index c9375a4cd..f3f56cb6d 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,8 @@ ## Unreleased +* `NEW` `@operator` declarations on generic classes now resolve class type parameters through the instantiated type: with `---@class Box` and `---@operator call(): T?`, calling a `Box` instance infers `string?` +* `FIX` Generic type parameters used in `@operator` annotations are now recognized as generics (and highlighted as such) instead of being treated as undefined type names * `NEW` Support type inference for `@field` and `@type` function declarations in method overrides [#3367](https://github.com/LuaLS/lua-language-server/issues/3367) * `FIX` Deduplicate documentation bindings for parameters * `FIX` Correct `math.type` meta return annotation to use `nil` instead of the string literal `'nil'` diff --git a/script/parser/luadoc.lua b/script/parser/luadoc.lua index a958d12b6..a783ee3f8 100644 --- a/script/parser/luadoc.lua +++ b/script/parser/luadoc.lua @@ -1905,6 +1905,7 @@ local function bindGeneric(binded) or doc.type == 'doc.class' or doc.type == 'doc.alias' or doc.type == 'doc.field' + or doc.type == 'doc.operator' or doc.type == 'doc.overload' then guide.eachSourceType(doc, 'doc.type.name', function (src) local name = src[1] diff --git a/script/vm/compiler.lua b/script/vm/compiler.lua index 5a673e5fa..3ad607bc8 100644 --- a/script/vm/compiler.lua +++ b/script/vm/compiler.lua @@ -280,6 +280,9 @@ local function containsGenericName(obj) return false end +---Public alias so that other vm modules (operator.lua) can reuse the check. +vm.containsGenericName = containsGenericName + ---Builds a map from generic parameter names to their concrete types ---@param uri uri ---@param classGlobal vm.global diff --git a/script/vm/operator.lua b/script/vm/operator.lua index 75defe852..264105d29 100644 --- a/script/vm/operator.lua +++ b/script/vm/operator.lua @@ -68,23 +68,49 @@ end ---@param op string ---@param value? parser.object ---@param result? vm.node +---@param uri? uri +---@param classGlobal? vm.global +---@param signs? parser.object[] ---@return vm.node? -local function checkOperators(operators, op, value, result) +local function checkOperators(operators, op, value, result, uri, classGlobal, signs) + -- For operators declared on a generic class and reached through an + -- instantiated type (`Box`), substitute the class type + -- parameters in the operand and return annotations. + local genericMap + if uri and classGlobal and signs then + genericMap = vm.getClassGenericMap(uri, classGlobal, signs) + end for _, operator in ipairs(operators) do if operator.op[1] ~= op or not operator.extends then goto CONTINUE end - if value and operator.exp then + local exp = operator.exp + local extends = operator.extends + if genericMap then + if exp and vm.containsGenericName(exp) then + exp = vm.cloneObject(exp, genericMap) + end + if vm.containsGenericName(extends) then + extends = vm.cloneObject(extends, genericMap) + end + end + if value and exp then local valueNode = vm.compileNode(value) - local expNode = vm.compileNode(operator.exp) - local uri = guide.getUri(operator) + local expNode = vm.compileNode(exp) + local opUri = guide.getUri(operator) for vo in valueNode:eachObject() do - if vm.isSubType(uri, vo, expNode) then + local child = vo + -- `vm.isSubType` has no notion of `doc.type.sign`; match an + -- instantiated type (`Box`) by its base class. + if child.type == 'doc.type.sign' and child.node and child.node[1] then + child = vm.getGlobal('type', child.node[1]) or child + end + if vm.isSubType(opUri, child, expNode) then if not result then result = vm.createNode() end - result:merge(vm.compileNode(operator.extends)) + result:merge(vm.compileNode(extends)) return result end end @@ -92,7 +118,7 @@ local function checkOperators(operators, op, value, result) if not result then result = vm.createNode() end - result:merge(vm.compileNode(operator.extends)) + result:merge(vm.compileNode(extends)) return result end ::CONTINUE:: @@ -122,6 +148,17 @@ function vm.runOperator(op, exp, value) end end end + if c.type == 'doc.type.sign' and c.node and c.node[1] then + local classGlobal = vm.getGlobal('type', c.node[1]) + if classGlobal then + for _, set in ipairs(classGlobal:getSets(uri)) do + if set.operators and #set.operators > 0 then + result = checkOperators(set.operators, op, value, result, + uri, classGlobal, c.signs) + end + end + end + end end return result end diff --git a/test/type_inference/generic_operator.lua b/test/type_inference/generic_operator.lua new file mode 100644 index 000000000..69c1c931e --- /dev/null +++ b/test/type_inference/generic_operator.lua @@ -0,0 +1,202 @@ +-- @operator on generic classes: class type parameters resolve through +-- the instantiated sign (e.g. `Box` + `call(): T?` -> `string?`). + +-- Baseline: non-generic class (must keep working) +TEST 'string' [[ +---@class Caller +---@operator call(): string +local c + +local = c() +]] + +-- call(): T? on an explicit sign +TEST 'string?' [[ +---@class Box +---@operator call(): T? +local box + +---@type Box +local b + +local = b() +]] + +-- Full chain: generic factory return + call operator +TEST 'string?' [[ +---@class Box +---@operator call(): T? + +---@generic T +---@param items T[] +---@return Box +local function make(items) end + +local test = {} ---@type string[] +local b = make(test) +local = b() +]] + +-- Binary operator, generic in extends only +TEST 'string' [[ +---@class Box +---@operator add(Box): T +local box + +---@type Box +local b + +local = b + b +]] + +-- Two type parameters +TEST 'integer' [[ +---@class Pair +---@operator call(): V +local pair + +---@type Pair +local p + +local = p() +]] + +-- Nested sign in the argument position +TEST 'Box?' [[ +---@class Box +---@operator call(): T? +local box + +---@type Box> +local b + +local = b() +]] + +-- Calling the nested result unwraps the inner parameter +TEST 'string?' [[ +---@class Box +---@operator call(): T? +local box + +---@type Box> +local b + +local outer = b() +local = outer() +]] + +-- Generic in the operand type of a binary operator +TEST 'string' [[ +---@class Box +---@operator add(Box): T +local box + +---@type Box +local b + +local = b + b +]] + +-- Signs on a non-generic class: no substitution, operator still works +TEST 'integer' [[ +---@class Plain +---@operator call(): integer +local plain + +---@type Plain +local p + +local = p() +]] + +-- Real-world shape: inherited generic class + factory function +TEST 'string?' [[ +---@class UIObject + +---@class SingleSelector : UIObject +---@operator call(): T? +local SingleSelector + +---@generic T +---@param items T[] +---@return SingleSelector +local function AddSingleSelector(items) end + +local test = {} ---@type string[] +local Items = AddSingleSelector(test) +local = Items() +]] + +-- Unparameterized reference: T stays unresolved and renders as a generic +-- (``), matching how unresolved function generics are displayed +TEST '?' [[ +---@class Box +---@operator call(): T? +local box + +---@type Box +local b + +local = b() +]] + +-- Highlighting: sign names used in class annotations must be converted to +-- `doc.generic.name` by `bindGeneric`, so semantic tokens color them like +-- `@generic` parameters. The expected string lists every doc.generic.name +-- in the file (sign declarations included), sorted and comma-joined. +local files = require 'files' +local guide = require 'parser.guide' + +local function TESTGENERICNAMES(expected) + return function (script) + files.setText(TESTURI, script) + local state = files.getState(TESTURI) + assert(state) + local found = {} + for _, doc in ipairs(state.ast.docs) do + -- eachSource instead of eachSourceType: the latter's type cache + -- is built during doc binding, before `bindGeneric` rewrites + -- `doc.type.name` nodes into `doc.generic.name` + guide.eachSource(doc, function (src) + if src.type == 'doc.generic.name' then + found[#found+1] = src[1] + end + end) + end + table.sort(found) + local got = table.concat(found, ',') + assert(got == expected, + ('doc.generic.name mismatch! Wanted: [%s] Got: [%s]') + :format(expected, got)) + files.remove(TESTURI) + end +end + +-- Declaration + usage in @field + usage in @operator; real types +-- (SingleSelectorModel) must stay doc.type.name +TESTGENERICNAMES 'T,T,T' [[ +---@class SingleSelectorModel + +---@class SingleSelector +---@field Model SingleSelectorModel +---@field Default T +---@operator call(): T? +local SingleSelector +]] + +-- Declarations of both classes + usage in inheritance arguments +TESTGENERICNAMES 'T,T,T' [[ +---@class Base + +---@class Child : Base +local Child +]] + +-- No signs -> no generic names anywhere +TESTGENERICNAMES '' [[ +---@class Plain +---@field x integer +---@operator call(): integer +local Plain +]] diff --git a/test/type_inference/init.lua b/test/type_inference/init.lua index b45a1a4dd..0236f7e9f 100644 --- a/test/type_inference/init.lua +++ b/test/type_inference/init.lua @@ -47,3 +47,4 @@ end require 'type_inference.common' require 'type_inference.param_match' require 'type_inference.field_override' +require 'type_inference.generic_operator'