-- Copyright 2012-18 Paul Kulchenko, ZeroBrane LLC
|
-- Integration with LuaInspect or LuaCheck
|
---------------------------------------------------------
|
|
local function create_checker()
|
if ide.config.staticanalyzer.luacheck then
|
local config = type(ide.config.staticanalyzer.luacheck) == "table" and ide.config.staticanalyzer.luacheck or {}
|
|
local luacheck = require("luacheck")
|
|
-- globals only need to be generated once the API has changed.
|
-- maybe this can be a module instead?
|
|
local function build_env_from_api(tbl, out)
|
out = out or {}
|
for k, v in pairs(tbl) do
|
if v.type ~= "keyword" then
|
out[k] = {fields = v.childs and build_env_from_api(v.childs)}
|
end
|
end
|
return out
|
end
|
|
local function build_env()
|
local globals = {}
|
|
for _, api in pairs(ide:GetInterpreter():GetAPI() or {}) do
|
-- not sure if this is how you're supposed to get an api
|
local ok, tbl = pcall(require, "api/lua/" .. api)
|
if ok then
|
build_env_from_api(tbl, globals)
|
end
|
end
|
|
return globals
|
end
|
|
return function(src, file)
|
local api_globals = build_env()
|
|
if config.options then
|
-- add user config globals to api table
|
for k, v in pairs(config.options.globals or {}) do
|
api_globals[k] = v
|
end
|
config.options.globals = api_globals
|
end
|
|
local default_options = {
|
max_line_length = false,
|
globals = api_globals,
|
-- http://luacheck.readthedocs.io/en/stable/warnings.html
|
ignore = config.ignore or {
|
"6..", -- whitespace and style warnings
|
},
|
}
|
local data = luacheck.check_strings({src}, config.options or default_options)
|
|
-- I think luacheck can support showing multiple errors
|
-- but warnings_from_string is meant to only show one
|
if data.errors > 0 or data.fatals > 0 then
|
local report = data[1][1]
|
return nil, luacheck.get_message(report), report.line, report.column
|
end
|
|
local warnings = {}
|
|
for _, report in ipairs(data[1]) do
|
local str = luacheck.get_message(report)
|
|
if config.reportcode then
|
str = str .. "(" .. report.code .. ")"
|
end
|
|
table.insert(warnings, ("%s:%d:%d: %s"):format(
|
file,
|
report.line,
|
report.column, -- not standard when using luainspect
|
str
|
))
|
end
|
|
return warnings
|
end
|
else
|
local LA, LI, T
|
|
local current_ast
|
local current_src
|
local current_file
|
|
local function init()
|
if LA then return end
|
|
-- metalua is using 'checks', which noticeably slows the execution
|
-- stab it with out own
|
package.loaded.checks = {} -- make `require 'checks'` work even without `checks` module
|
rawset(_G, "checks", function() end) -- provide `checks` function
|
|
LA = require "luainspect.ast"
|
LI = require "luainspect.init"
|
T = require "luainspect.types"
|
end
|
|
local function pos2line(pos)
|
return pos and 1 + select(2, current_src:sub(1,pos):gsub(".-\n[^\n]*", ""))
|
end
|
|
local function show_warnings(top_ast, globinit)
|
local warnings = {}
|
local function warn(msg, linenum, path)
|
warnings[#warnings+1] = (path or current_file or "?") .. ":" .. (linenum or pos2line(current_ast.pos) or 0) .. ": " .. msg
|
end
|
local function known(o) return not T.istype[o] end
|
local function index(f) -- build abc.def.xyz name recursively
|
if not f or f.tag ~= 'Index' or not f[1] or not f[2] then return end
|
local main = f[1].tag == 'Id' and f[1][1] or index(f[1])
|
return main and type(f[2][1]) == "string" and (main .. '.' .. f[2][1]) or nil
|
end
|
local globseen, isseen, fieldseen = globinit or {}, {}, {}
|
LA.walk(top_ast, function(ast)
|
current_ast = ast
|
local path, line = tostring(ast.lineinfo):gsub('<C|','<'):match('<([^|]+)|L(%d+)')
|
local name = ast[1]
|
-- check if we're masking a variable in the same scope
|
if ast.localmasking and name ~= '_' and
|
ast.level == ast.localmasking.level then
|
local linenum = ast.localmasking.lineinfo
|
and tostring(ast.localmasking.lineinfo.first):match('|L(%d+)')
|
or pos2line(ast.localmasking.pos)
|
local parent = ast.parent and ast.parent.parent
|
local func = parent and parent.tag == 'Localrec'
|
warn("local " .. (func and 'function' or 'variable') .. " '" ..
|
name .. "' masks earlier declaration " ..
|
(linenum and "on line " .. linenum or "in the same scope"),
|
line, path)
|
end
|
if ast.localdefinition == ast and not ast.isused and
|
not ast.isignore then
|
local parent = ast.parent and ast.parent.parent
|
local isparam = parent and parent.tag == 'Function'
|
if isparam then
|
if name ~= 'self' then
|
local func = parent.parent and parent.parent.parent
|
local assignment = not func.tag or func.tag == 'Set' or func.tag == 'Localrec'
|
-- anonymous functions can also be defined in expressions,
|
-- for example, 'Op' or 'Return' tags
|
local expression = not assignment and func.tag
|
local func1 = func[1][1]
|
local fname = assignment and func1 and type(func1[1]) == 'string'
|
and func1[1] or (func1 and func1.tag == 'Index' and index(func1))
|
-- "function foo(bar)" => func.tag == 'Set'
|
-- `Set{{`Id{"foo"}},{`Function{{`Id{"bar"}},{}}}}
|
-- "local function foo(bar)" => func.tag == 'Localrec'
|
-- "local _, foo = 1, function(bar)" => func.tag == 'Local'
|
-- "print(function(bar) end)" => func.tag == nil
|
-- "a = a or function(bar) end" => func.tag == nil
|
-- "return(function(bar) end)" => func.tag == 'Return'
|
-- "function tbl:foo(bar)" => func.tag == 'Set'
|
-- `Set{{`Index{`Id{"tbl"},`String{"foo"}}},{`Function{{`Id{"self"},`Id{"bar"}},{}}}}
|
-- "function tbl.abc:foo(bar)" => func.tag == 'Set'
|
-- `Set{{`Index{`Index{`Id{"tbl"},`String{"abc"}},`String{"foo"}}},{`Function{{`Id{"self"},`Id{"bar"}},{}}}},
|
warn("unused parameter '" .. name .. "'" ..
|
(func and (assignment or expression)
|
and (fname and func.tag
|
and (" in function '" .. fname .. "'")
|
or " in anonymous function")
|
or ""),
|
line, path)
|
end
|
else
|
if parent and parent.tag == 'Localrec' then -- local function foo...
|
warn("unused local function '" .. name .. "'", line, path)
|
else
|
warn("unused local variable '" .. name .. "'; "..
|
"consider removing or replacing with '_'", line, path)
|
end
|
end
|
end
|
-- added check for "fast" mode as ast.seevalue relies on value evaluation,
|
-- which is very slow even on simple and short scripts
|
if ide.config.staticanalyzer.infervalue and ast.isfield
|
and not(known(ast.seevalue.value) and ast.seevalue.value ~= nil) then
|
local var = index(ast.parent)
|
local parent = ast.parent and var
|
and (" in '"..var:gsub("%."..name.."$","").."'")
|
or ""
|
if not fieldseen[name..parent] then
|
fieldseen[name..parent] = true
|
local tblref = ast.parent and ast.parent[1]
|
local localparam = (tblref and tblref.localdefinition
|
and tblref.localdefinition.isparam)
|
if not localparam then
|
warn("first use of unknown field '" .. name .."'"..parent,
|
ast.lineinfo and tostring(ast.lineinfo.first):match('|L(%d+)'), path)
|
end
|
end
|
elseif ast.tag == 'Id' and not ast.localdefinition and not ast.definedglobal then
|
if not globseen[name] then
|
globseen[name] = true
|
local parent = ast.parent
|
-- if being called and not one of the parameters
|
if parent and parent.tag == 'Call' and parent[1] == ast then
|
warn("first use of unknown global function '" .. name .. "'", line, path)
|
else
|
warn("first use of unknown global variable '" .. name .. "'", line, path)
|
end
|
end
|
elseif ast.tag == 'Id' and not ast.localdefinition and ast.definedglobal then
|
local parent = ast.parent and ast.parent.parent
|
if parent and parent.tag == 'Set' and not globseen[name] -- report assignments to global
|
-- only report if it is on the left side of the assignment
|
-- this is a bit tricky as it can be assigned as part of a, b = c, d
|
-- `Set{ {lhs+} {expr+} } -- lhs1, lhs2... = e1, e2...
|
and parent[1] == ast.parent
|
and parent[2][1].tag ~= "Function" then -- but ignore global functions
|
warn("first assignment to global variable '" .. name .. "'", line, path)
|
globseen[name] = true
|
end
|
elseif (ast.tag == 'Set' or ast.tag == 'Local') and #(ast[2]) > #(ast[1]) then
|
warn(("value discarded in multiple assignment: %d values assigned to %d variable%s")
|
:format(#(ast[2]), #(ast[1]), #(ast[1]) > 1 and 's' or ''), line, path)
|
end
|
local vast = ast.seevalue or ast
|
local note = vast.parent
|
and (vast.parent.tag == 'Call' or vast.parent.tag == 'Invoke')
|
and vast.parent.note
|
if note and not isseen[vast.parent] and type(name) == "string" then
|
isseen[vast.parent] = true
|
warn("function '" .. name .. "': " .. note, line, path)
|
end
|
end)
|
return warnings
|
end
|
|
local function cleanError(err)
|
return err and err:gsub(".-:%d+: file%s+",""):gsub(", line (%d+), char %d+", ":%1")
|
end
|
|
init()
|
|
return function(src, file)
|
init()
|
|
local ast, err, linenum, colnum = LA.ast_from_string(src, file)
|
if not ast and err then return nil, cleanError(err), linenum, colnum end
|
|
LI.uninspect(ast)
|
if ide.config.staticanalyzer.infervalue then
|
local tokenlist = LA.ast_to_tokenlist(ast, src)
|
LI.clear_cache()
|
LI.inspect(ast, tokenlist, src)
|
LI.mark_related_keywords(ast, tokenlist, src)
|
else
|
-- stub out LI functions that depend on tokenlist,
|
-- which is not built in the "fast" mode
|
local ec, iv = LI.eval_comments, LI.infer_values
|
LI.eval_comments, LI.infer_values = function() end, function() end
|
|
LI.inspect(ast, nil, src)
|
LA.ensure_parents_marked(ast)
|
|
LI.eval_comments, LI.infer_values = ec, iv
|
end
|
|
local globinit = {arg = true} -- skip `arg` global variable
|
local spec = ide:FindSpec(wx.wxFileName(file):GetExt())
|
for k in pairs(spec and GetApi(spec.apitype or "none").ac.childs or {}) do
|
globinit[k] = true
|
end
|
|
current_src = src
|
current_file = file
|
return show_warnings(ast, globinit)
|
end
|
end
|
end
|
|
local checkers = {}
|
local function warnings_from_string(...)
|
local checktype = (ide.config.staticanalyzer.luacheck
|
-- luacheck globals depend on the interpreter, so create different checkers if needed
|
and "luacheck" .. (ide:GetInterpreter():GetFileName() or "")
|
or "luainspect")
|
if not checkers[checktype] then checkers[checktype] = create_checker() end
|
return checkers[checktype](...)
|
end
|
|
function AnalyzeFile(file)
|
local src, err = FileRead(file)
|
if not src and err then return nil, TR("Can't open file '%s': %s"):format(file, err) end
|
|
return warnings_from_string(src, file)
|
end
|
|
function AnalyzeString(src, file)
|
return warnings_from_string(src, file or "<string>")
|
end
|
|
local frame = ide.frame
|
|
-- insert after "Compile" item
|
local _, menu, compilepos = ide:FindMenuItem(ID.COMPILE)
|
if compilepos then
|
menu:Insert(compilepos+1, ID.ANALYZE, TR("Analyze")..KSC(ID.ANALYZE), TR("Analyze the source code"))
|
end
|
|
local function analyzeProgram(editor)
|
-- save all files (if requested) for "infervalue" analysis to keep the changes on disk
|
if ide.config.editor.saveallonrun and ide.config.staticanalyzer.infervalue then SaveAll(true) end
|
if ide:GetLaunchedProcess() == nil and not ide:GetDebugger():IsConnected() then ClearOutput() end
|
ide:GetOutput():Write("Analyzing the source code")
|
frame:Update()
|
|
local editorText = editor:GetTextDyn()
|
local doc = ide:GetDocument(editor)
|
local filePath = doc:GetFilePath() or doc:GetFileName()
|
local warn, err = warnings_from_string(editorText, filePath)
|
if err then -- report compilation error
|
ide:Print((": not completed.\n%s"):format(err))
|
return false
|
end
|
|
ide:Print((": %s warning%s.")
|
:format(#warn > 0 and #warn or 'no', #warn == 1 and '' or 's'))
|
ide:GetOutput():Write(table.concat(warn, "\n") .. (#warn > 0 and "\n" or ""))
|
|
return true -- analyzed ok
|
end
|
|
frame:Connect(ID.ANALYZE, wx.wxEVT_COMMAND_MENU_SELECTED,
|
function ()
|
ide:GetOutput():Activate()
|
local editor = ide:GetEditor()
|
if not analyzeProgram(editor) then
|
CompileProgram(editor, { reportstats = false, keepoutput = true })
|
end
|
end)
|
frame:Connect(ID.ANALYZE, wx.wxEVT_UPDATE_UI,
|
function (event) event:Enable(ide:GetEditor() ~= nil) end)
|