Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased
<!-- Add all new changes here. They will be moved under a version at release -->
* `NEW` `@operator` declarations on generic classes now resolve class type parameters through the instantiated type: with `---@class Box<T>` and `---@operator call(): T?`, calling a `Box<string>` 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'`
Expand Down
1 change: 1 addition & 0 deletions script/parser/luadoc.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions script/vm/compiler.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 44 additions & 7 deletions script/vm/operator.lua
Original file line number Diff line number Diff line change
Expand Up @@ -68,31 +68,57 @@ 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<string>`), 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<string>`) 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
else
if not result then
result = vm.createNode()
end
result:merge(vm.compileNode(operator.extends))
result:merge(vm.compileNode(extends))
return result
end
::CONTINUE::
Expand Down Expand Up @@ -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
Expand Down
202 changes: 202 additions & 0 deletions test/type_inference/generic_operator.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
-- @operator on generic classes: class type parameters resolve through
-- the instantiated sign (e.g. `Box<string>` + `call(): T?` -> `string?`).

-- Baseline: non-generic class (must keep working)
TEST 'string' [[
---@class Caller
---@operator call(): string
local c

local <?v?> = c()
]]

-- call(): T? on an explicit sign
TEST 'string?' [[
---@class Box<T>
---@operator call(): T?
local box

---@type Box<string>
local b

local <?v?> = b()
]]

-- Full chain: generic factory return + call operator
TEST 'string?' [[
---@class Box<T>
---@operator call(): T?

---@generic T
---@param items T[]
---@return Box<T>
local function make(items) end

local test = {} ---@type string[]
local b = make(test)
local <?v?> = b()
]]

-- Binary operator, generic in extends only
TEST 'string' [[
---@class Box<T>
---@operator add(Box): T
local box

---@type Box<string>
local b

local <?v?> = b + b
]]

-- Two type parameters
TEST 'integer' [[
---@class Pair<K, V>
---@operator call(): V
local pair

---@type Pair<string, integer>
local p

local <?v?> = p()
]]

-- Nested sign in the argument position
TEST 'Box<string>?' [[
---@class Box<T>
---@operator call(): T?
local box

---@type Box<Box<string>>
local b

local <?outer?> = b()
]]

-- Calling the nested result unwraps the inner parameter
TEST 'string?' [[
---@class Box<T>
---@operator call(): T?
local box

---@type Box<Box<string>>
local b

local outer = b()
local <?v?> = outer()
]]

-- Generic in the operand type of a binary operator
TEST 'string' [[
---@class Box<T>
---@operator add(Box<T>): T
local box

---@type Box<string>
local b

local <?v?> = b + b
]]

-- Signs on a non-generic class: no substitution, operator still works
TEST 'integer' [[
---@class Plain
---@operator call(): integer
local plain

---@type Plain<string>
local p

local <?v?> = p()
]]

-- Real-world shape: inherited generic class + factory function
TEST 'string?' [[
---@class UIObject

---@class SingleSelector<T> : UIObject
---@operator call(): T?
local SingleSelector

---@generic T
---@param items T[]
---@return SingleSelector<T>
local function AddSingleSelector(items) end

local test = {} ---@type string[]
local Items = AddSingleSelector(test)
local <?val?> = Items()
]]

-- Unparameterized reference: T stays unresolved and renders as a generic
-- (`<T>`), matching how unresolved function generics are displayed
TEST '<T>?' [[
---@class Box<T>
---@operator call(): T?
local box

---@type Box
local b

local <?v?> = 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 <T> + usage in @field + usage in @operator; real types
-- (SingleSelectorModel) must stay doc.type.name
TESTGENERICNAMES 'T,T,T' [[
---@class SingleSelectorModel

---@class SingleSelector<T>
---@field Model SingleSelectorModel
---@field Default T
---@operator call(): T?
local SingleSelector
]]

-- Declarations <T> of both classes + usage in inheritance arguments
TESTGENERICNAMES 'T,T,T' [[
---@class Base<T>

---@class Child<T> : Base<T>
local Child
]]

-- No signs -> no generic names anywhere
TESTGENERICNAMES '' [[
---@class Plain
---@field x integer
---@operator call(): integer
local Plain
]]
1 change: 1 addition & 0 deletions test/type_inference/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,4 @@ end
require 'type_inference.common'
require 'type_inference.param_match'
require 'type_inference.field_override'
require 'type_inference.generic_operator'
Loading