diff --git a/changelog.md b/changelog.md index b6caa5050..b7b99b6fa 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,7 @@ # changelog ## Unreleased +* `NEW` diagnostic: `no-unknown-operations` ## 3.16.0 diff --git a/locale/en-us/script.lua b/locale/en-us/script.lua index f8a38b9ab..a08c3cbb6 100644 --- a/locale/en-us/script.lua +++ b/locale/en-us/script.lua @@ -90,6 +90,10 @@ DIAG_COUNT_DOWN_LOOP = 'Do you mean `{}` ?' DIAG_UNKNOWN = 'Can not infer type.' +DIAG_UNKNOWN_OPERATION_CALL = +'Cannot infer call result for type {}.' +DIAG_UNKNOWN_OPERATION_OPERATOR = +'Cannot infer `{}` result for types {} and {}.' DIAG_DEPRECATED = 'Deprecated.' DIAG_DIFFERENT_REQUIRES = diff --git a/locale/en-us/setting.lua b/locale/en-us/setting.lua index 0c136333a..d910749d8 100644 --- a/locale/en-us/setting.lua +++ b/locale/en-us/setting.lua @@ -425,6 +425,8 @@ config.diagnostics['unnecessary-assert'] = 'Enable diagnostics for redundant assertions on truthy values.' config.diagnostics['no-unknown'] = 'Enable diagnostics for cases in which the type cannot be inferred.' +config.diagnostics['no-unknown-operations'] = +'Enable diagnostics for cases in which the result type of an operation cannot be inferred.' config.diagnostics['not-yieldable'] = 'Enable diagnostics for calls to `coroutine.yield()` when it is not permitted.' config.diagnostics['param-type-mismatch'] = diff --git a/script/core/diagnostics/no-unknown-operations.lua b/script/core/diagnostics/no-unknown-operations.lua new file mode 100644 index 000000000..9e0714e9a --- /dev/null +++ b/script/core/diagnostics/no-unknown-operations.lua @@ -0,0 +1,58 @@ +local files = require 'files' +local guide = require 'parser.guide' +local lang = require 'language' +local vm = require 'vm' +local await = require 'await' + +---@async +return function (uri, callback) + local state = files.getState(uri) + if not state then return end + -- TODO: no-unknown doesn't do this but missing-local-export-doc does, is this actually needed? + if not state.ast then return end + + -- calls are complicated because unknown arguments may or may not cause an introduction of an unknown type + -- integer(unknown) :: unknown should count as introducing an unknown, but function(unknown) :: unknown should not. We can't directly + -- check function because it might be overloaded or have a call operator defined. + ---@async + guide.eachSourceType(state.ast, 'call', function (source) + await.delay() + + local resultInfer = vm.getInfer(source):view(uri) + if resultInfer ~= 'unknown' then return end + local functionType = vm.getInfer(source.node) + if functionType:view(uri) == 'unknown' then return end -- we can't say anything about what unknown types support + if functionType:isCallable(uri) then return end + callback { + start = source.start, + finish = source.finish, + message = lang.script('DIAG_UNKNOWN_OPERATION_CALL', functionType:view(uri)), + } + end) + + -- binary operators are quite similar to function calls, they introduce an unknown if the result is unknown and none of the + -- parameters are unknown, or if the left side is known to not implement the operator + + ---@async + guide.eachSourceType(state.ast, 'binary', function (source) + await.delay() + + local resultInfer = vm.getInfer(source) + if resultInfer:view(uri) ~= 'unknown' then return end + local left, right = source[1], source[2] + local leftInfer, rightInfer = vm.getInfer(left), vm.getInfer(right) + if leftInfer:view(uri) == 'unknown' then return end + if rightInfer:view(uri) ~= 'unknown' then + -- the operator doesn't work for these types + callback { + start = source.start, + finish = source.finish, + message = lang.script('DIAG_UNKNOWN_OPERATION_OPERATOR', source.op.type, leftInfer:view(uri), rightInfer:view(uri)), + } + return + end + + -- TODO: it seems that if the operator is defined and the other arg is unkown it is always inferred as the + -- return type of the operator, so we can't check that case currently + end) +end diff --git a/script/proto/diagnostic.lua b/script/proto/diagnostic.lua index 72761493e..d1eb91ff6 100644 --- a/script/proto/diagnostic.lua +++ b/script/proto/diagnostic.lua @@ -160,6 +160,7 @@ m.register { m.register { 'no-unknown', + 'no-unknown-operations', } { group = 'strong', severity = 'Warning', diff --git a/script/vm/infer.lua b/script/vm/infer.lua index 6f21a76ab..ced3f723d 100644 --- a/script/vm/infer.lua +++ b/script/vm/infer.lua @@ -383,6 +383,12 @@ function mt:hasFunction(uri) or self._hasDocFunction == true end +---@param uri uri +---@return boolean +function mt:isCallable(uri) + return self:hasFunction(uri) or #vm.getOperators("call", self.node, uri) ~= 0 +end + ---@param uri uri function mt:_computeViews(uri) if self.views then diff --git a/script/vm/operator.lua b/script/vm/operator.lua index cde5f8df8..1d605eab1 100644 --- a/script/vm/operator.lua +++ b/script/vm/operator.lua @@ -1,15 +1,15 @@ ---@class vm -local vm = require 'vm.vm' -local util = require 'utility' -local guide = require 'parser.guide' -local config = require 'config' +local vm = require 'vm.vm' +local util = require 'utility' +local guide = require 'parser.guide' +local config = require 'config' -vm.UNARY_OP = { +vm.UNARY_OP = { 'unm', 'bnot', 'len', } -vm.BINARY_OP = { +vm.BINARY_OP = { 'add', 'sub', 'mul', @@ -24,17 +24,17 @@ vm.BINARY_OP = { 'shr', 'concat', } -vm.OTHER_OP = { +vm.OTHER_OP = { 'call', } -local unaryMap = { +local unaryMap = { ['-'] = 'unm', ['~'] = 'bnot', ['#'] = 'len', } -local binaryMap = { +local binaryMap = { ['+'] = 'add', ['-'] = 'sub', ['*'] = 'mul', @@ -50,7 +50,7 @@ local binaryMap = { ['..'] = 'concat', } -local otherMap = { +local otherMap = { ['()'] = 'call', } @@ -95,13 +95,12 @@ local function checkOperators(operators, op, value, result) end ---@param op string ----@param exp parser.object ----@param value? parser.object ----@return vm.node? -function vm.runOperator(op, exp, value) - local uri = guide.getUri(exp) - local node = vm.compileNode(exp) - local result +---@param node vm.node +---@param uri string +---@return parser.object[] +function vm.getOperators(op, node, uri) + ---@type parser.object[] + local operators = {} for c in node:eachObject() do if c.type == 'string' or c.type == 'doc.type.string' then @@ -111,11 +110,27 @@ function vm.runOperator(op, exp, value) ---@cast c vm.global for _, set in ipairs(c:getSets(uri)) do if set.operators and #set.operators > 0 then - result = checkOperators(set.operators, op, value, result) + for _, operator in ipairs(set.operators) do + if operator.op[1] == op then + table.insert(operators, operator) + end + end end end end end + return operators +end + +---@param op string +---@param exp parser.object +---@param value? parser.object +---@return vm.node? +function vm.runOperator(op, exp, value) + local node = vm.compileNode(exp) + local uri = guide.getUri(exp) + local operators = vm.getOperators(op, node, uri) + local result = checkOperators(operators, op, value, nil) return result end @@ -247,10 +262,10 @@ vm.binarySwitch = util.switch() local op = source.op.type if a and b then local result = op == '<<' and a << b - or op == '>>' and a >> b - or op == '&' and a & b - or op == '|' and a | b - or op == '~' and a ~ b + or op == '>>' and a >> b + or op == '&' and a & b + or op == '|' and a | b + or op == '~' and a ~ b ---@diagnostic disable-next-line: missing-fields vm.setNode(source, { type = 'integer', @@ -281,18 +296,18 @@ vm.binarySwitch = util.switch() local b = vm.getNumber(source[2]) local op = source.op.type local zero = b == 0 - and ( op == '%' - or op == '/' - or op == '//' - ) + and (op == '%' + or op == '/' + or op == '//' + ) if a and b and not zero then - local result = op == '+' and a + b - or op == '-' and a - b - or op == '*' and a * b - or op == '/' and a / b - or op == '%' and a % b - or op == '//' and a // b - or op == '^' and a ^ b + local result = op == '+' and a + b + or op == '-' and a - b + or op == '*' and a * b + or op == '/' and a / b + or op == '%' and a % b + or op == '//' and a // b + or op == '^' and a ^ b ---@diagnostic disable-next-line: missing-fields vm.setNode(source, { type = (op == '//' or math.type(result) == 'integer') and 'integer' or 'number', @@ -353,10 +368,10 @@ vm.binarySwitch = util.switch() end) : case '..' : call(function (source) - local a = vm.getString(source[1]) - or vm.getNumber(source[1]) - local b = vm.getString(source[2]) - or vm.getNumber(source[2]) + local a = vm.getString(source[1]) + or vm.getNumber(source[1]) + local b = vm.getString(source[2]) + or vm.getNumber(source[2]) if a and b then if type(a) == 'number' or type(b) == 'number' then local uri = guide.getUri(source) @@ -390,13 +405,13 @@ vm.binarySwitch = util.switch() local infer2 = vm.getInfer(source[2]) if ( infer1:hasType(uri, 'integer') - or infer1:hasType(uri, 'number') - or infer1:hasType(uri, 'string') + or infer1:hasType(uri, 'number') + or infer1:hasType(uri, 'string') ) and ( infer2:hasType(uri, 'integer') - or infer2:hasType(uri, 'number') - or infer2:hasType(uri, 'string') + or infer2:hasType(uri, 'number') + or infer2:hasType(uri, 'string') ) then vm.setNode(source, vm.declareGlobal('type', 'string')) return @@ -419,17 +434,17 @@ vm.binarySwitch = util.switch() local b = vm.getNumber(source[2]) if a and b then local op = source.op.type - local result = op == '>' and a > b - or op == '<' and a < b - or op == '>=' and a >= b - or op == '<=' and a <= b + local result = op == '>' and a > b + or op == '<' and a < b + or op == '>=' and a >= b + or op == '<=' and a <= b ---@diagnostic disable-next-line: missing-fields vm.setNode(source, { type = 'boolean', start = source.start, finish = source.finish, parent = source, - [1] =result, + [1] = result, }) else vm.setNode(source, vm.declareGlobal('type', 'boolean')) diff --git a/test/diagnostics/init.lua b/test/diagnostics/init.lua index fc97e4389..4280246c1 100644 --- a/test/diagnostics/init.lua +++ b/test/diagnostics/init.lua @@ -110,6 +110,7 @@ check 'unnecessary-assert' check 'newfield-call' check 'newline-call' check 'not-yieldable' +check 'no-unknown-operations' check 'param-type-mismatch' check 'redefined-local' check 'redundant-parameter' diff --git a/test/diagnostics/no-unknown-operations.lua b/test/diagnostics/no-unknown-operations.lua new file mode 100644 index 000000000..4b82a18f3 --- /dev/null +++ b/test/diagnostics/no-unknown-operations.lua @@ -0,0 +1,80 @@ +TEST [[ +local x = 5 + + +---@overload fun(): string +local x = 5 +x() + +---@class x +---@operator call(): string +local x = {} +x() +]] + +TEST [[ +---@type nil +local x + + +---@overload fun(): string +local x +x() + +---@class x +---@operator call(): string +local x +x() +]] + +TEST [[ +---@type unknown +local x +x() +]] + +TEST [[ +local function f() +end +f() +]] + +TEST [[ + +]] + +TEST [[ + +]] + +TEST [[ +(function() end)() +]] + +TEST [[ +local _ = 1 + 2 +local _ = +]] + +TEST [[ +local _ = "a" .. "b" +---@class Vec2 + +---@type Vec2, Vec2 +local a, b = {}, {} +local _ = +]] + +TEST [[ +---@type unknown +local a +local b = a + 2 +]] + +TEST [[ +---@param name string +---@return string +local function greet(name) + return "Hello " .. +end +]]