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 m.pauseCount = 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 ---@param uri? uri ---@param force? boolean function m.clearAll(uri, force) local scp if uri then scp = scope.getScope(uri) end if force then for luri in files.eachFile() do if not scp or scope.getScope(luri) == scp then m.clear(luri, force) end end else for luri in pairs(m.cache) do if not scp or scope.getScope(luri) == scp then m.clear(luri) end 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 uri uri function m.stopScopeDiag(uri) local scp = scope.getScope(uri) local scopeID = 'diagnosticsScope:' .. scp:getName() await.close(scopeID) end ---@param event string ---@param uri uri function m.refreshScopeDiag(event, uri) if not ws.isReady(uri) then return end 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) repeat await.sleep(0.1) until not m.isPaused() 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() 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(uri) 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 ---@return boolean function m.isPaused() return m.pauseCount > 0 end function m.pause() m.pauseCount = m.pauseCount + 1 end function m.resume() m.pauseCount = m.pauseCount - 1 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.stopScopeDiag(uri) m.refresh(uri) m.refreshScopeDiag('OnSave', uri) elseif ev == 'create' then m.stopScopeDiag(uri) m.refresh(uri) m.refreshScopeDiag('OnSave', uri) elseif ev == 'update' then m.stopScopeDiag(uri) 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