local await = require 'await' local proto = require 'proto.proto' local define = require 'proto.define' local lang = require 'language' local files = require 'files' local config = require 'config' local core = require 'core.diagnostics' local util = require 'utility' local ws = require 'workspace' local progress = require "progress" local client = require 'client' local converter = require 'proto.converter' local loading = require 'workspace.loading' local scope = require 'workspace.scope' local time = require 'bee.time' local ltable = require 'linked-table' local furi = require 'file-uri' local json = require 'json' local fw = require 'filewatch' local vm = require 'vm.vm' ---@class diagnosticProvider local m = {} m.cache = {} m.sleepRest = 0.0 m.scopeDiagCount = 0 local function concat(t, sep) if type(t) ~= 'table' then return t end return table.concat(t, sep) end local function buildSyntaxError(uri, err) local state = files.getState(uri) local text = files.getText(uri) if not text or not state then return end local message = lang.script('PARSER_' .. err.type, err.info) if err.version then local version = err.info and err.info.version or config.get(uri, 'Lua.runtime.version') message = message .. ('(%s)'):format(lang.script('DIAG_NEED_VERSION' , concat(err.version, '/') , version )) end local related = err.info and err.info.related local relatedInformation if related then relatedInformation = {} for _, rel in ipairs(related) do local rmessage if rel.message then rmessage = lang.script('PARSER_' .. rel.message) else rmessage = text:sub(rel.start, rel.finish) end local relUri = rel.uri or uri local relState = files.getState(relUri) if relState then relatedInformation[#relatedInformation+1] = { message = rmessage, location = converter.location(relUri, converter.packRange(relState, rel.start, rel.finish)), } end end end return { code = err.type:lower():gsub('_', '-'), range = converter.packRange(state, err.start, err.finish), severity = define.DiagnosticSeverity[err.level], source = lang.script.DIAG_SYNTAX_CHECK, message = message, data = 'syntax', relatedInformation = relatedInformation, } end local function buildDiagnostic(uri, diag) local state = files.getState(uri) if not state then return end local relatedInformation if diag.related then relatedInformation = {} for _, rel in ipairs(diag.related) do local rtext = files.getText(rel.uri) if not rtext then goto CONTINUE end local relState = files.getState(rel.uri) if not relState then goto CONTINUE end relatedInformation[#relatedInformation+1] = { message = rel.message or rtext:sub(rel.start, rel.finish), location = converter.location(rel.uri, converter.packRange(relState, rel.start, rel.finish)) } ::CONTINUE:: end end return { range = converter.packRange(state, diag.start, diag.finish), source = lang.script.DIAG_DIAGNOSTICS, severity = diag.level, message = diag.message, code = diag.code, tags = diag.tags, data = diag.data, relatedInformation = relatedInformation, } end local function mergeDiags(a, b, c) if not a and not b and not c then return nil end local t = {} local function merge(diags) if not diags then return end for i = 1, #diags do local diag = diags[i] local severity = diag.severity if severity == define.DiagnosticSeverity.Hint or severity == define.DiagnosticSeverity.Information then if #t > 10000 then goto CONTINUE end end t[#t+1] = diag ::CONTINUE:: end end merge(a) merge(b) merge(c) if #t == 0 then return nil end return t end -- enable `push`, disable `clear` function m.clear(uri, force) await.close('diag:' .. uri) if m.cache[uri] == nil and not force then return end m.cache[uri] = nil proto.notify('textDocument/publishDiagnostics', { uri = uri, diagnostics = {}, }) log.info('clearDiagnostics', uri) end function m.clearCacheExcept(uris) local excepts = {} for _, uri in ipairs(uris) do excepts[uri] = true end for uri in pairs(m.cache) do if not excepts[uri] then m.cache[uri] = false end end end function m.clearAll(force) if force then for luri in files.eachFile() do m.clear(luri, force) end else for luri in pairs(m.cache) do m.clear(luri) end end end function m.syntaxErrors(uri, ast) if #ast.errs == 0 then return nil end local results = {} pcall(function () local disables = util.arrayToHash(config.get(uri, 'Lua.diagnostics.disable')) for _, err in ipairs(ast.errs) do local id = err.type:lower():gsub('_', '-') if not disables[id] and not vm.isDiagDisabledAt(uri, err.start, id, true) then results[#results+1] = buildSyntaxError(uri, err) end end end) return results end local function copyDiagsWithoutSyntax(diags) if not diags then return nil end local copyed = {} for _, diag in ipairs(diags) do if diag.data ~= 'syntax' then copyed[#copyed+1] = diag end end return copyed end ---@async ---@param uri uri ---@return boolean local function isValid(uri) if not config.get(uri, 'Lua.diagnostics.enable') then return false end if files.isLibrary(uri, true) then local status = config.get(uri, 'Lua.diagnostics.libraryFiles') if status == 'Disable' then return false elseif status == 'Opened' then if not files.isOpen(uri) then return false end end end if ws.isIgnored(uri) then local status = config.get(uri, 'Lua.diagnostics.ignoredFiles') if status == 'Disable' then return false elseif status == 'Opened' then if not files.isOpen(uri) then return false end end end local scheme = furi.split(uri) local disableScheme = config.get(uri, 'Lua.diagnostics.disableScheme') if util.arrayHas(disableScheme, scheme) then return false end return true end ---@async function m.doDiagnostic(uri, isScopeDiag) if not isValid(uri) then return end await.delay() local state = files.getState(uri) if not state then m.clear(uri) return end local version = files.getVersion(uri) local prog = progress.create(uri, lang.script.WINDOW_DIAGNOSING, 0.5) prog:setMessage(ws.getRelativePath(uri)) --log.debug('Diagnostic file:', uri) local syntax = m.syntaxErrors(uri, state) local diags = {} local lastDiag = copyDiagsWithoutSyntax(m.cache[uri]) local function pushResult() tracy.ZoneBeginN 'mergeSyntaxAndDiags' local _ = tracy.ZoneEnd local full = mergeDiags(syntax, lastDiag, diags) --log.debug(('Pushed [%d] results'):format(full and #full or 0)) if not full then m.clear(uri) return end if util.equal(m.cache[uri], full) then return end m.cache[uri] = full if not files.exists(uri) then m.clear(uri) return end proto.notify('textDocument/publishDiagnostics', { uri = uri, version = version, diagnostics = full, }) log.debug('publishDiagnostics', uri, #full) end pushResult() local lastPushClock = time.time() ---@async xpcall(core, log.error, uri, isScopeDiag, function (result) diags[#diags+1] = buildDiagnostic(uri, result) if not isScopeDiag and time.time() - lastPushClock >= 500 then lastPushClock = time.time() pushResult() end end, function (checkedName) if not lastDiag then return end for i, diag in ipairs(lastDiag) do if diag.code == checkedName then lastDiag[i] = lastDiag[#lastDiag] lastDiag[#lastDiag] = nil end end end) lastDiag = nil pushResult() end ---@param uri uri function m.resendDiagnostic(uri) local full = m.cache[uri] if not full then return end if not files.exists(uri) then m.clear(uri) return end local version = files.getVersion(uri) proto.notify('textDocument/publishDiagnostics', { uri = uri, version = version, diagnostics = full, }) log.debug('publishDiagnostics', uri, #full) end ---@async ---@return table|nil result ---@return boolean? unchanged function m.pullDiagnostic(uri, isScopeDiag) if not isValid(uri) then return nil, util.equal(m.cache[uri], nil) end await.delay() local state = files.getState(uri) if not state then return nil, util.equal(m.cache[uri], nil) end local prog = progress.create(uri, lang.script.WINDOW_DIAGNOSING, 0.5) prog:setMessage(ws.getRelativePath(uri)) local syntax = m.syntaxErrors(uri, state) local diags = {} xpcall(core, log.error, uri, isScopeDiag, function (result) diags[#diags+1] = buildDiagnostic(uri, result) end) local full = mergeDiags(syntax, diags) if util.equal(m.cache[uri], full) then return full, true end m.cache[uri] = full return full end ---@param event string ---@param uri uri function m.refreshScopeDiag(event, uri) if not ws.isReady(uri) then return end local scp = scope.getScope(uri) local scopeID = 'diagnosticsScope:' .. scp:getName() await.close(scopeID) local eventConfig = config.get(uri, 'Lua.diagnostics.workspaceEvent') if eventConfig ~= event then return end ---@async await.call(function () local delay = config.get(uri, 'Lua.diagnostics.workspaceDelay') / 1000 if delay < 0 then return end await.sleep(math.max(delay, 0.2)) m.diagnosticsScope(uri) end) end ---@param uri uri function m.refresh(uri) if not ws.isReady(uri) then return end await.close('diag:' .. uri) ---@async await.call(function () await.setID('diag:' .. uri) await.sleep(0.1) xpcall(m.doDiagnostic, log.error, uri) end) end ---@async local function askForDisable(uri) if m.dontAskedForDisable then return end local delay = 30 local delayTitle = lang.script('WINDOW_DELAY_WS_DIAGNOSTIC', delay) local item = proto.awaitRequest('window/showMessageRequest', { type = define.MessageType.Info, message = lang.script.WINDOW_SETTING_WS_DIAGNOSTIC, actions = { { title = lang.script.WINDOW_DONT_SHOW_AGAIN, }, { title = delayTitle, }, { title = lang.script.WINDOW_DISABLE_DIAGNOSTIC, }, } }) if not item then return end if item.title == lang.script.WINDOW_DONT_SHOW_AGAIN then m.dontAskedForDisable = true elseif item.title == delayTitle then client.setConfig { { key = 'Lua.diagnostics.workspaceDelay', action = 'set', value = delay * 1000, uri = uri, } } elseif item.title == lang.script.WINDOW_DISABLE_DIAGNOSTIC then client.setConfig { { key = 'Lua.diagnostics.workspaceDelay', action = 'set', value = -1, uri = uri, } } end end local function clearMemory() if m.scopeDiagCount > 0 then return end vm.clearNodeCache() collectgarbage() end ---@async function m.awaitDiagnosticsScope(suri, callback) local scp = scope.getScope(suri) if scp.type == 'fallback' then return end while loading.count() > 0 do await.sleep(1.0) end m.scopeDiagCount = m.scopeDiagCount + 1 local scopeDiag = util.defer(function () m.scopeDiagCount = m.scopeDiagCount - 1 clearMemory() end) local clock = os.clock() local bar = progress.create(suri, lang.script.WORKSPACE_DIAGNOSTIC, 1) local cancelled bar:onCancel(function () log.info('Cancel workspace diagnostics') cancelled = true ---@async await.call(function () askForDisable(suri) end) end) local uris = files.getAllUris(suri) local sortedUris = ltable() for _, uri in ipairs(uris) do if files.isOpen(uri) then sortedUris:pushHead(uri) else sortedUris:pushTail(uri) end end log.info(('Diagnostics scope [%s], files count:[%d]'):format(scp:getName(), #uris)) local i = 0 for uri in sortedUris:pairs() do while loading.count() > 0 do await.sleep(1.0) end i = i + 1 bar:setMessage(('%d/%d'):format(i, #uris)) bar:setPercentage(i / #uris * 100) callback(uri) await.delay() if cancelled then log.info('Break workspace diagnostics') break end end bar:remove() log.info(('Diagnostics scope [%s] finished, takes [%.3f] sec.'):format(scp:getName(), os.clock() - clock)) end function m.diagnosticsScope(uri, force) if not ws.isReady(uri) then return end if not force and not config.get(uri, 'Lua.diagnostics.enable') then m.clearAll() return end if not force and config.get(uri, 'Lua.diagnostics.workspaceDelay') < 0 then return end local scp = scope.getScope(uri) local id = 'diagnosticsScope:' .. scp:getName() await.close(id) await.call(function () ---@async await.sleep(0.0) m.awaitDiagnosticsScope(uri, function (fileUri) xpcall(m.doDiagnostic, log.error, fileUri, true) end) end, id) end ---@async function m.pullDiagnosticScope(callback) local processing = 0 for _, scp in ipairs(scope.folders) do if ws.isReady(scp.uri) and config.get(scp.uri, 'Lua.diagnostics.enable') then local id = 'diagnosticsScope:' .. scp:getName() await.close(id) await.call(function () ---@async processing = processing + 1 local _ = util.defer(function () processing = processing - 1 end) local delay = config.get(scp.uri, 'Lua.diagnostics.workspaceDelay') / 1000 if delay < 0 then return end print(delay) await.sleep(math.max(delay, 0.2)) print('start') m.awaitDiagnosticsScope(scp.uri, function (fileUri) local suc, result, unchanged = xpcall(m.pullDiagnostic, log.error, fileUri, true) if suc then callback { uri = fileUri, result = result, unchanged = unchanged, version = files.getVersion(fileUri), } end end) end, id) end end -- sleep for ever while true do await.sleep(1.0) end end function m.refreshClient() log.debug('Refresh client diagnostics') proto.request('workspace/diagnostic/refresh', json.null) end ws.watch(function (ev, uri) if ev == 'reload' then m.diagnosticsScope(uri) m.refreshClient() end end) files.watch(function (ev, uri) ---@async if ev == 'remove' then m.clear(uri) m.refresh(uri) elseif ev == 'update' then m.refresh(uri) m.refreshScopeDiag('OnChange', uri) elseif ev == 'open' then if ws.isReady(uri) then m.resendDiagnostic(uri) xpcall(m.doDiagnostic, log.error, uri) end elseif ev == 'close' then if files.isLibrary(uri, true) or ws.isIgnored(uri) then m.clear(uri) end elseif ev == 'save' then m.refreshScopeDiag('OnSave', uri) end end) config.watch(function (uri, key, value, oldValue) if util.stringStartWith(key, 'Lua.diagnostics') or util.stringStartWith(key, 'Lua.spell') or util.stringStartWith(key, 'Lua.doc') then if value ~= oldValue then m.diagnosticsScope(uri) m.refreshClient() end end end) fw.event(function (ev, path) if util.stringEndWith(path, '.editorconfig') then for _, scp in ipairs(ws.folders) do m.diagnosticsScope(scp.uri) m.refreshClient() end end end) return m