diff --git a/changelog.md b/changelog.md index 343a2e40c..424475c81 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,8 @@ * `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` diff --git a/script/core/diagnostics/no-unknown.lua b/script/core/diagnostics/no-unknown.lua index e706931ad..51f5858fa 100644 --- a/script/core/diagnostics/no-unknown.lua +++ b/script/core/diagnostics/no-unknown.lua @@ -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 diff --git a/script/vm/tracer.lua b/script/vm/tracer.lua index cc6d10e59..2a578aa8b 100644 --- a/script/vm/tracer.lua +++ b/script/vm/tracer.lua @@ -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 elseif action.op.type == 'or' then outNode = outNode or topNode:copy() local topNode1, outNode1 = tracer:lookIntoChild(action[1], topNode, outNode) @@ -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