local pub = require 'pub' local fs = require 'bee.filesystem' local furi = require 'file-uri' local files = require 'files' local config = require 'config' local glob = require 'glob' local platform = require 'bee.platform' local await = require 'await' local client = require 'client' local util = require 'utility' local fw = require 'filewatch' local scope = require 'workspace.scope' local loading = require 'workspace.loading' local inspect = require 'inspect' local lang = require 'language' ---@class workspace local m = {} m.type = 'workspace' m.watchList = {} --- 注册事件 ---@param callback async fun(ev: string, uri: uri) function m.watch(callback) m.watchList[#m.watchList+1] = callback end function m.onWatch(ev, uri) for _, callback in ipairs(m.watchList) do await.call(function () callback(ev, uri) end) end end function m.initRoot(uri) m.rootUri = uri log.info('Workspace init root: ', uri) local logPath = fs.path(LOGPATH) / (uri:gsub('[/:]+', '_') .. '.log') client.logMessage('Log', 'Log path: ' .. furi.encode(logPath:string())) log.info('Log path: ', logPath) log.init(ROOT, logPath) end --- 初始化工作区 function m.create(uri) if furi.isValid(uri) then uri = furi.normalize(uri) end log.info('Workspace create: ', uri) local scp = scope.createFolder(uri) m.folders[#m.folders+1] = scp if uri == furi.encode '/' or uri == furi.encode(os.getenv 'HOME' or '') then client.showMessage('Error', lang.script('WORKSPACE_NOT_ALLOWED', furi.decode(uri))) scp:set('bad root', true) end end function m.remove(uri) log.info('Workspace remove: ', uri) for i, scp in ipairs(m.folders) do if scp.uri == uri then scp:remove() table.remove(m.folders, i) scp:set('ready', false) scp:set('nativeMatcher', nil) scp:set('libraryMatcher', nil) scp:removeAllLinks() m.flushFiles(scp) return end end end function m.reset() ---@type scope[] m.folders = {} m.rootUri = nil end m.reset() function m.getRootUri(uri) local scp = scope.getScope(uri) return scp.uri end local globInteferFace = { type = function (path) local result pcall(function () if fs.is_directory(path) then result = 'directory' else result = 'file' end end) return result end, list = function (path) local fullPath = fs.path(path) if not fs.is_directory(fullPath) then return nil end local paths = {} pcall(function () for fullpath in fs.pairs(fullPath) do paths[#paths+1] = fullpath:string() end end) return paths end } --- 创建排除文件匹配器 ---@param scp scope function m.getNativeMatcher(scp) if scp:get 'nativeMatcher' then return scp:get 'nativeMatcher' end local pattern = {} for path, ignore in pairs(config.get(scp.uri, 'files.exclude')) do if ignore then log.debug('Ignore by exclude:', path) pattern[#pattern+1] = path end end if scp.uri and config.get(scp.uri, 'Lua.workspace.useGitIgnore') then local buf = util.loadFile(furi.decode(scp.uri) .. '/.gitignore') if buf then for line in buf:gmatch '[^\r\n]+' do if line:sub(1, 1) ~= '#' then log.debug('Ignore by .gitignore:', line) pattern[#pattern+1] = line end end end buf = util.loadFile(furi.decode(scp.uri).. '/.git/info/exclude') if buf then for line in buf:gmatch '[^\r\n]+' do if line:sub(1, 1) ~= '#' then log.debug('Ignore by .git/info/exclude:', line) pattern[#pattern+1] = line end end end end if scp.uri and config.get(scp.uri, 'Lua.workspace.ignoreSubmodules') then local buf = util.loadFile(furi.decode(scp.uri) .. '/.gitmodules') if buf then for path in buf:gmatch('path = ([^\r\n]+)') do log.debug('Ignore by .gitmodules:', path) pattern[#pattern+1] = path end end end for _, path in ipairs(config.get(scp.uri, 'Lua.workspace.library')) do path = m.getAbsolutePath(scp.uri, path) if path then log.debug('Ignore by library:', path) debug[#pattern+1] = path end end for _, path in ipairs(config.get(scp.uri, 'Lua.workspace.ignoreDir')) do log.debug('Ignore directory:', path) pattern[#pattern+1] = path end local matcher = glob.gitignore(pattern, { root = scp.uri and furi.decode(scp.uri), ignoreCase = platform.OS == 'Windows', }, globInteferFace) scp:set('nativeMatcher', matcher) return matcher end --- 创建代码库筛选器 ---@param scp scope function m.getLibraryMatchers(scp) if scp:get 'libraryMatcher' then return scp:get 'libraryMatcher' end log.debug('Build library matchers:', scp) local pattern = {} for path, ignore in pairs(config.get(scp.uri, 'files.exclude')) do if ignore then log.debug('Ignore by exclude:', path) pattern[#pattern+1] = path end end for _, path in ipairs(config.get(scp.uri, 'Lua.workspace.ignoreDir')) do log.debug('Ignore directory:', path) pattern[#pattern+1] = path end local librarys = {} for _, path in ipairs(config.get(scp.uri, 'Lua.workspace.library')) do path = m.getAbsolutePath(scp.uri, path) if path then librarys[m.normalize(path)] = true end end log.debug('meta path:', scp:get 'metaPath') if scp:get 'metaPath' then librarys[m.normalize(scp:get 'metaPath')] = true end local matchers = {} for path in pairs(librarys) do if fs.exists(fs.path(path)) then local nPath = fs.absolute(fs.path(path)):string() local matcher = glob.gitignore(pattern, { root = path, ignoreCase = platform.OS == 'Windows', }, globInteferFace) matchers[#matchers+1] = { uri = furi.encode(nPath), matcher = matcher } end end scp:set('libraryMatcher', matchers) log.debug('library matcher:', inspect(matchers)) return matchers end --- 文件是否被忽略 ---@async ---@param uri uri function m.isIgnored(uri) local scp = scope.getScope(uri) local path = m.getRelativePath(uri) local ignore = m.getNativeMatcher(scp) if not ignore then return false end return ignore(path) end ---@async function m.isValidLuaUri(uri) if not files.isLua(uri) then return false end if m.isIgnored(uri) and not files.isLibrary(uri) then return false end return true end ---@async function m.awaitLoadFile(uri) m.awaitReady(uri) local scp = scope.getScope(uri) local ld = loading.create(scp) local native = m.getNativeMatcher(scp) log.info('Scan files at:', uri) ---@async native:scan(furi.decode(uri), function (path) local uri = files.getRealUri(furi.encode(path)) scp:get('cachedUris')[uri] = true ld:loadFile(uri) end) ld:loadAll(uri) end function m.removeFile(uri) for _, scp in ipairs(m.folders) do if scp:isChildUri(uri) or scp:isLinkedUri(uri) then local cachedUris = scp:get 'cachedUris' if cachedUris and cachedUris[uri] then cachedUris[uri] = nil files.delRef(uri) end end end end --- 预读工作区内所有文件 ---@async ---@param scp scope function m.awaitPreload(scp) await.close('preload:' .. scp:getName()) await.setID('preload:' .. scp:getName()) await.sleep(0.1) scp:flushGC() if scp:isRemoved() then return end local ld = loading.create(scp) scp:set('loading', ld) log.info('Preload start:', scp:getName()) local native = m.getNativeMatcher(scp) local librarys = m.getLibraryMatchers(scp) if scp.uri and not scp:get('bad root') then log.info('Scan files at:', scp:getName()) scp:gc(fw.watch(m.normalize(furi.decode(scp.uri)))) local count = 0 ---@async native:scan(furi.decode(scp.uri), function (path) local uri = files.getRealUri(furi.encode(path)) scp:get('cachedUris')[uri] = true ld:loadFile(uri) end, function (_) ---@async count = count + 1 if count == 100000 then client.showMessage('Warning', lang.script('WORKSPACE_SCAN_TOO_MUCH', count, furi.decode(scp.uri))) end end) end for _, libMatcher in ipairs(librarys) do log.info('Scan library at:', libMatcher.uri) local count = 0 scp:gc(fw.watch(furi.decode(libMatcher.uri))) scp:addLink(libMatcher.uri) ---@async libMatcher.matcher:scan(furi.decode(libMatcher.uri), function (path) local uri = files.getRealUri(furi.encode(path)) scp:get('cachedUris')[uri] = true ld:loadFile(uri, libMatcher.uri) end, function () ---@async count = count + 1 if count == 100000 then client.showMessage('Warning', lang.script('WORKSPACE_SCAN_TOO_MUCH', count, furi.decode(libMatcher.uri))) end end) end -- must wait for other scopes to add library await.sleep(0.1) log.info(('Found %d files at:'):format(ld.max), scp:getName()) ld:loadAll(scp:getName()) log.info('Preload finish at:', scp:getName()) end --- 查找符合指定file path的所有uri ---@param path string function m.findUrisByFilePath(path) if type(path) ~= 'string' then return {} end local myUri = furi.encode(path) local vm = require 'vm' local resultCache = vm.getCache 'findUrisByFilePath.result' if resultCache[path] then return resultCache[path] end local results = {} for uri in files.eachFile() do if uri == myUri then results[#results+1] = uri end end resultCache[path] = results return results end ---@param path string ---@return string function m.normalize(path) path = path:gsub('%$%{(.-)%}', function (key) if key == '3rd' then return (ROOT / 'meta' / '3rd'):string() end if key:sub(1, 4) == 'env:' then local env = os.getenv(key:sub(5)) return env end end) path = util.expandPath(path) path = path:gsub('^%.[/\\]+', '') for _ = 1, 1000 do if path:sub(1, 2) == '..' then break end local count path, count = path:gsub('[^/\\]+[/\\]+%.%.[/\\]', '/', 1) if count == 0 then break end end if platform.OS == 'Windows' then path = path:gsub('[/\\]+', '\\') :gsub('[/\\]+$', '') :gsub('^(%a:)$', '%1\\') else path = path:gsub('[/\\]+', '/') :gsub('[/\\]+$', '') end return path end ---@param folderUri? uri ---@param path string ---@return string? function m.getAbsolutePath(folderUri, path) path = m.normalize(path) if fs.path(path):is_relative() then if not folderUri then return nil end local folderPath = furi.decode(folderUri) path = m.normalize(folderPath .. '/' .. path) end return path end ---@param uriOrPath uri|string ---@return string ---@return boolean suc function m.getRelativePath(uriOrPath) local path, uri if uriOrPath:sub(1, 5) == 'file:' then path = furi.decode(uriOrPath) uri = uriOrPath else path = uriOrPath uri = furi.encode(uriOrPath) end local scp = scope.getScope(uri) if not scp.uri then local relative = m.normalize(path) return relative:gsub('^[/\\]+', ''), false end local _, pos = m.normalize(path):find(furi.decode(scp.uri), 1, true) if pos then return m.normalize(path:sub(pos + 1)):gsub('^[/\\]+', ''), true else return m.normalize(path):gsub('^[/\\]+', ''), false end end ---@param scp scope function m.reload(scp) ---@async await.call(function () m.awaitReload(scp) end) end function m.init() if m.rootUri then for _, folder in ipairs(scope.folders) do m.reload(folder) end end m.reload(scope.fallback) end ---@param scp scope function m.flushFiles(scp) local cachedUris = scp:get 'cachedUris' scp:set('cachedUris', {}) if not cachedUris then return end for uri in pairs(cachedUris) do files.delRef(uri) end collectgarbage() collectgarbage() -- TODO: wait maillist collectgarbage 'restart' end ---@param scp scope function m.resetFiles(scp) local cachedUris = scp:get 'cachedUris' if cachedUris then for uri in pairs(cachedUris) do files.resetText(uri) end end for uri in pairs(files.openMap) do if scope.getScope(uri) == scp then files.resetText(uri) end end end ---@async ---@param scp scope function m.awaitReload(scp) scp:set('ready', false) scp:set('nativeMatcher', nil) scp:set('libraryMatcher', nil) scp:removeAllLinks() m.flushFiles(scp) m.onWatch('startReload', scp.uri) m.awaitPreload(scp) scp:set('ready', true) local waiting = scp:get('waitingReady') if waiting then scp:set('waitingReady', nil) for _, waker in ipairs(waiting) do waker() end end m.onWatch('reload', scp.uri) end ---@return scope function m.getFirstScope() return m.folders[1] or scope.fallback end ---等待工作目录加载完成 ---@async function m.awaitReady(uri) if m.isReady(uri) then return end local scp = scope.getScope(uri) local waitingReady = scp:get('waitingReady') or scp:set('waitingReady', {}) await.wait(function (waker) waitingReady[#waitingReady+1] = waker end) end ---@param uri uri function m.isReady(uri) local scp = scope.getScope(uri) return scp:get('ready') == true end function m.getLoadingProcess(uri) local scp = scope.getScope(uri) ---@type workspace.loading local ld = scp:get 'loading' if ld then return ld.read, ld.max else return 0, 0 end end config.watch(function (uri, key, value, oldValue) if key:find '^Lua.runtime' or key:find '^Lua.workspace' or key:find '^Lua.type' or key:find '^files' then if value ~= oldValue then local scp = scope.getScope(uri) m.reload(scp) m.resetFiles(scp) end end end) fw.event(function (ev, path) ---@async local uri = furi.encode(path) if ev == 'create' then log.debug('FileChangeType.Created', uri) m.awaitLoadFile(uri) elseif ev == 'delete' then log.debug('FileChangeType.Deleted', uri) files.remove(uri) m.removeFile(uri) local childs = files.getChildFiles(uri) for _, curi in ipairs(childs) do log.debug('FileChangeType.Deleted.Child', curi) files.remove(curi) m.removeFile(uri) end elseif ev == 'change' then if m.isValidLuaUri(uri) then -- 如果文件处于关闭状态,则立即更新;否则等待didChange协议来更新 if not files.isOpen(uri) then files.setText(uri, pub.awaitTask('loadFile', furi.decode(uri)), false) end end end local filename = fs.path(path):filename():string() -- 排除类文件发生更改需要重新扫描 if filename == '.gitignore' or filename == '.gitmodules' then local scp = scope.getScope(uri) if scp.type ~= 'fallback' then m.reload(scp) end end end) return m