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 @@ -4,6 +4,8 @@
<!-- Add all new changes here. They will be moved under a version at release -->
* `CHG` Modified the `ResolveRequire` function to pass the source URI as a third argument.
* `CHG` Improved the output of test failures during development
* `FIX` Fix type inference for `x == nil and "default" or x` idiom [#2236](https://github.com/LuaLS/lua-language-server/issues/2236)
* `FIX` Fix type loss for assignments inside `if`/`for` blocks due to circular dependency in tracer [#2374](https://github.com/LuaLS/lua-language-server/issues/2374) [#2494](https://github.com/LuaLS/lua-language-server/issues/2494)

## 3.17.1
`2026-01-20`
Expand Down
28 changes: 23 additions & 5 deletions script/core/diagnostics/no-unknown.lua
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,29 @@ return function (uri, callback)
guide.eachSourceTypes(ast.ast, types, function (source)
await.delay()
if vm.getInfer(source):view(uri) == 'unknown' then
callback {
start = source.start,
finish = source.finish,
message = lang.script('DIAG_UNKNOWN'),
}
-- When a node only contains a 'variable' object whose base
-- declaration has a known type, this is a false positive caused
-- by circular dependency during compilation, not a true unknown.
local dominated = false
local node = vm.getNode(source)
if node then
for n in node:eachObject() do
if n.type == 'variable' and n.base and n.base.value then
local baseView = vm.getInfer(n.base):view(uri)
if baseView ~= 'unknown' then
dominated = true
break
end
end
end
end
if not dominated then
callback {
start = source.start,
finish = source.finish,
message = lang.script('DIAG_UNKNOWN'),
}
end
end
end)
end
47 changes: 44 additions & 3 deletions script/vm/tracer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -638,9 +638,22 @@ local lookIntoChild = util.switch()
tracer:lookIntoChild(action[2], topNode)
return topNode, outNode
end
if action.op.type == 'and' then
topNode = tracer:lookIntoChild(action[1], topNode, topNode:copy())
topNode = tracer:lookIntoChild(action[2], topNode, topNode:copy())
if action.op.type == 'and' then
local topNode1, outNode1 = tracer:lookIntoChild(action[1], topNode, topNode:copy())
topNode = tracer:lookIntoChild(action[2], topNode1, topNode1:copy())
-- When the right side of `and` is a truthy literal (string, number,
-- true, table, function), the `and` can only be false when the left
-- side is false. Propagate the narrowed outNode so that patterns
-- like `x == nil and "default" or x` correctly infer x as non-nil.
local tp2 = action[2].type
if tp2 == 'string'
or tp2 == 'number'
or tp2 == 'integer'
or tp2 == 'table'
or tp2 == 'function'
or (tp2 == 'boolean' and action[2][1] == true) then
outNode = outNode1
end
Comment on lines +644 to +656
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current check for a truthy right-hand side of an and expression is limited to literals. This could be made more robust and general by using vm.compileNode and the existing alwaysTruthy() method on the resulting node. This would handle not just literals but any expression that can be determined to be always truthy, such as constants or function calls returning known truthy values.

            -- When the right side of `and` is a truthy expression, the `and`
            -- can only be false when the left side is false. Propagate the
            -- narrowed outNode so that patterns like `x == nil and "default" or x`
            -- correctly infer x as non-nil.
            local node2 = vm.compileNode(action[2])
            if node2:alwaysTruthy() then
                outNode = outNode1
            end

elseif action.op.type == 'or' then
outNode = outNode or topNode:copy()
local topNode1, outNode1 = tracer:lookIntoChild(action[1], topNode, outNode)
Expand Down Expand Up @@ -844,12 +857,40 @@ function mt:calcNode(source)
return
end
if self.assignMap[source] then
-- Guard against circular dependency: when this setlocal is already
-- being compiled (e.g. if-handler's getNode triggers calcNode for
-- a setlocal whose value is currently being compiled), skip
-- lookIntoBlock to avoid propagating incomplete types and setting
-- marks that would prevent later correct processing.
if self._compilingAssigns and self._compilingAssigns[source] then
self.nodes[source] = vm.compileNode(source)
return
end
if not self._compilingAssigns then
self._compilingAssigns = {}
end
self._compilingAssigns[source] = true
local node = vm.compileNode(source)
-- When the compiled node has no known type (only contains a 'variable'
-- due to circular dependency), fall back to the variable's base
-- declaration node. This prevents incomplete nodes from propagating
-- through control flow analysis (e.g. if-blocks inside for-loops),
-- which would otherwise cause the type to degrade to 'unknown'.
if not node:hasKnownType()
and self.mode == 'local'
and self.source.type == 'variable'
and self.source.base then
local baseNode = vm.compileNode(self.source.base)
if baseNode:hasKnownType() then
node = baseNode
end
end
self.nodes[source] = node
local parentBlock = guide.getParentBlock(source)
if parentBlock then
self:lookIntoBlock(parentBlock, source.finish, node)
end
self._compilingAssigns[source] = nil
return
end
end
Expand Down
Loading