· 7 years ago · Sep 07, 2018, 12:56 AM
1--[[
2Module loader.
3This sets up the functions for loading modules.
4
5YOU SHOULD LOAD THIS FILE AS SOON AS POSSIBLE - I'd suggest before autorun, so it exists for autorun scripts.
6You can do this by taking one of the lua/includes/extensions files (I suggest vector.lua) and copying it to
7your project, adding a include("../../modules.lua") call at the end.
8
9DEFINING AN ENTRY POINT
10The import function will always be available, because this function is loaded first.
11However, if there is a module that you would like to be called first, you can define it
12as an entry point. This means the module will be reloaded whenever modules are refreshed,
13so if you want a file to be included in this custom refresh feature, define it as an entry
14point.
15
16To do so, just call declareEntryPoint():
17 - declareEntryPoint()
18
19TODO: Use global export as entry point?
20
21That's it. declareEntryPoint will be ignored if it is called while a module is being imported,
22because if you are importing a module, *that module will already be refreshed*. declareEntryPoint
23should only be used in autorun scripts or scripts that are loaded by things other than this one.
24
25THINGS THIS DOES FOR YOU
26 * Automatically AddCSLuaFile's stuff that's supposed to be on the client.
27 * Manages realms automatically for you. If you state that a library is clientside-only,
28 calling it on the server will give you an error message.
29 * Avoids global state. Every module is only loaded once, the first time it is called,
30 and none of its globals are allowed to escape.
31 * Creates and destroys hooks automatically.
32 * Has its own auto-refresh and it's better too.
33
34CREATING A MODULE
35 It's pretty similar to Node.js because I know how that one works.
36 In your module, create an export statement like this (at the end of your file):
37 return export({
38 Properties = {
39 function1 = function() end,
40 function2 = function() end
41 },
42 Hooks = {
43 ["Think"] = function() end,
44 ["HUDDraw"] = {function() end, function() end}
45 },
46 ServerHooks = {},
47 ClientHooks = {},
48 Realms = { "CLIENT", "SERVER" },
49 Resources = { "materials/gameaim/ga_skin.png" },
50 -- called when unloaded
51 CustomCleanup = function() end
52 })
53
54 Then, when you want to use the module, create an import statement:
55 local util = import("../util.lua")
56
57 This will contain all the functions exported in the export statement, so you can write
58 util.function1(), util.function2(), etc.
59
60!!! IMPORTANT NOTE !!!
61 If you are creating a module that is client only, you need to provide a dummy export for the server.
62 Otherwise, the server will execute all your client code and then get confused that none of the
63 clientside libraries exist. So, place a call to exportClientServer in a SERVER if block after imports
64 at the top of a file. This kinda sucks, but I can't think of a way around this.
65
66 Defines = import("defines.lua")
67
68 -- no params = nothing exported on server
69 if SERVER then return exportClientServer() end
70
71 -- you can also pass resources if you need those to still be sent
72 if SERVER then return exportClientServer({ Resources = {"materials/gameaim/ga_skin.png"} }) end
73]]--
74
75AddCSLuaFile()
76
77-- used to keep track of stuff between refreshes
78EP_GlobalState = EP_GlobalState or {}
79EP_GlobalState.Modules = EP_GlobalState.Modules or {}
80EP_GlobalState.EntryPoints = {}
81EP_GlobalState.HookN = EP_GlobalState.HookN or 0
82EP_GlobalState.DejaVu = EP_GlobalState.DejaVu or false
83
84local thisFilename = string.GetFileFromFilename(debug.getinfo(1, "S").short_src)
85
86local function epDebugPrint(...)
87 if not _G.epDebugPrintEnable then return end
88 print("[DEBUG] ", ...)
89end
90
91local isDebug = game.SinglePlayer() or _G.epDebugModeEnable
92epDebugPrint("debug mode: ", isDebug)
93
94-- used by below - shortens each part of the path until we've made it
95local function shortenToLuaFolderParts(pathParts)
96 -- work through until we find an acceptable substring
97 for k, v in pairs(pathParts) do
98 if v == "lua" then
99 local newPath = table.concat(pathParts, "/", k + 1)
100 epDebugPrint("path found: " .. newPath)
101 return newPath
102 end
103 end
104
105 epDebugPrint("no path")
106 return nil
107end
108
109-- shortens the given path to the portion relative to the lua folder, if possible
110local function shortenToLuaFolder(path)
111 return shortenToLuaFolderParts(string.Explode("/", path, false))
112end
113
114-- same as above
115local function subTable(t, s, e)
116 return {unpack(t, s, e)}
117end
118
119local function simplifyPathParts(trail, remaining)
120 if #remaining <= 0 then
121 return table.concat(trail, "/")
122 end
123
124 local nextBit = remaining[1]
125 if nextBit == "." then
126 return simplifyPathParts(trail, subTable(remaining, 2))
127 elseif nextBit == ".." and #trail > 0 then
128 return simplifyPathParts(subTable(trail, 1, #trail - 1), subTable(remaining, 2))
129 elseif nextBit == ".." and #trail == 0 then
130 error("path leaves lua folder")
131 else
132 table.insert(trail, nextBit)
133 return simplifyPathParts(trail, subTable(remaining, 2))
134 end
135end
136
137-- removes all those strange path bits
138local function simplifyPath(path)
139 return simplifyPathParts({}, string.Explode("/", path))
140end
141
142-- finds the first stack level that doesn't refer to this file
143local function findCorrectStackLevel()
144 local level = 0
145 local info = {}
146 repeat
147 level = level + 1
148 info = debug.getinfo(level, "S")
149 epDebugPrint("stack level ", level, ": ", (info or {}).short_src)
150 until info == nil or string.GetFileFromFilename(info.short_src) ~= thisFilename
151
152 if info == nil or string.GetFileFromFilename(info.short_src) == "LuaCmd" then
153 return level - 1
154 end
155
156 return level
157end
158
159-- returns the name of the file at the given stack level
160local function getCurrentFile(stackLevel)
161 local info = debug.getinfo(stackLevel, "S")
162 return shortenToLuaFolder(simplifyPath(info.short_src))
163end
164
165-- returns the first file in the callstack that isn't this one, if one exists
166local function getFirstDifferentFile()
167 local stackLevel = findCorrectStackLevel()
168 local file = getCurrentFile(stackLevel)
169 return file or getCurrentFile(1)
170end
171
172-- generate a unique id for a hook
173local function generateHookId(module, name)
174 EP_GlobalState.HookN = EP_GlobalState.HookN + 1
175 return string.format("%s_%s_%i_%i", name, module, SysTime(), EP_GlobalState.HookN)
176end
177
178-- load all the hooks in the given table
179local function loadModuleHooks(t, moduleName, hooks)
180 for hookName, hookCb in pairs(hooks or {}) do
181 local id = generateHookId(moduleName, hookName)
182 t[id] = hookName
183 epDebugPrint("adding hook ", hookName, " id ", id)
184 hook.Add(hookName, id, hookCb)
185 end
186end
187
188-- we are in the wrong realm for this library - stub the functions so they show errors
189local function stubWrongFunctions(t, realm)
190 for k, func in pairs(t.Properties or {}) do
191 if type(t.Properties[k]) == "function" then
192 t.Properties[k] = function(...)
193 error("can't call function " .. k .. " in the " .. realm .. " realm!")
194 end
195 end
196 end
197end
198
199-- patch to allow pairs() on metatable
200local origNext = next
201function next(t,k)
202 local m = getmetatable(t)
203 local n = m and m.__next or origNext
204 return n(t,k)
205end
206
207function pairs(t)
208 return next, t, nil
209end
210
211-- creates a table that always points to the most recent version of the module at path
212local function createModuleReference(path)
213 local mt = {
214 __index = function(t, key)
215 return EP_GlobalState.Modules[path].Properties[key]
216 end,
217 __newindex = function() end,
218 __call = function(t, ...) return EP_GlobalState.Modules[path].Properties(...) end,
219 __next = function(t, k) return next(EP_GlobalState.Modules[path].Properties, k) end
220 }
221
222 return setmetatable({}, mt)
223end
224
225local validKeys = { "Properties", "Hooks", "ServerHooks", "ClientHooks", "Realms", "Resources", "CustomCleanup" }
226
227-- loads the given module table
228-- inPlace: instead of replacing whatever module is at that path, load onto it
229local function loadModule(path, name, t, inPlace)
230 local msgs = {}
231 for k, v in pairs(t) do
232 if not table.HasValue(validKeys, k) then
233 table.insert(msgs, "Unknown key in module definition " + path + ": " + k)
234 end
235 end
236
237 if #msgs > 0 then
238 error(table.concat(msgs, "\n"))
239 end
240
241 local moduleInfo = {}
242 if inPlace then
243 moduleInfo = EP_GlobalState.Modules[path]
244 epDebugPrint("loading in-place")
245 else
246 moduleInfo = { name = name }
247 end
248
249 moduleInfo.Hooks = {}
250 loadModuleHooks(moduleInfo.Hooks, name, t.Hooks)
251
252 -- load hooks for specific realms
253 if CLIENT then
254 loadModuleHooks(moduleInfo.ClientHooks, name, t.ClientHooks)
255 elseif SERVER then
256 loadModuleHooks(moduleInfo.ServerHooks, name, t.ServerHooks)
257 end
258
259 local isServer = table.HasValue(t.Realms or {}, "SERVER")
260 local isClient = table.HasValue(t.Realms or {}, "CLIENT")
261
262 -- if we're on server, let's handle resources and AddCSLuaFile-ing
263 if SERVER then
264 for _, path in pairs(t.Resources or {}) do
265 resource.AddFile(path)
266 end
267
268 -- only send to client if it's supposed to be there
269 if isClient then
270 AddCSLuaFile(path)
271 end
272 end
273
274 moduleInfo.Properties = {}
275 for k, v in pairs(t.Properties or {}) do
276 moduleInfo.Properties[k] = v
277 end
278
279 -- stub incorrectly loaded functions
280 if SERVER and not isServer then
281 stubWrongFunctions(moduleInfo, "server")
282 elseif CLIENT and not isClient then
283 stubWrongFunctions(moduleInfo, "client")
284 end
285
286 -- record last modified time for auto refresh
287 moduleInfo.LastModified = file.Time(path, "LUA")
288
289 if not inPlace then
290 EP_GlobalState.Modules[path] = moduleInfo
291 end
292
293 return EP_GlobalState.Modules[path]
294end
295
296-- unloads a module, including deleting all its hooks
297-- soft: doesn't delete the table. new module can then be loaded into its place
298local function unloadModule(path, soft)
299 local mod = EP_GlobalState.Modules[path]
300
301 if mod.CustomCleanup ~= nil then
302 mod.CustomCleanup()
303 end
304
305 for id, name in pairs(mod.Hooks or {}) do
306 hook.Remove(name, id)
307 end
308
309 if SERVER then
310 for id, name in pairs(mod.ServerHooks or {}) do
311 hook.Remove(name, id)
312 end
313 elseif CLIENT then
314 for id, name in pairs(mod.ClientHooks or {}) do
315 hook.Remove(name, id)
316 end
317 end
318
319 epDebugPrint("unloaded module ", path)
320
321 if not soft then
322 EP_GlobalState.Modules[path] = nil
323 end
324end
325
326-- converts path to name - removes special chars
327local function pathToName(path)
328 return string.gsub(path, "[^%w]+", "_")
329end
330
331local function performExport(table, importPath, inPlace)
332 local path = importPath or getCurrentFile(findCorrectStackLevel())
333 epDebugPrint("loading file at path ", path)
334
335 loadModule(path, pathToName(path), table, inPlace)
336 epDebugPrint("loaded module ", pathToName(path))
337 return createModuleReference(path)
338end
339
340-- defined locally here, made global when importing using setfenv
341-- uses a nested function to pass info to export without globals
342local function export(table)
343 return function(importPath, inPlace)
344 return performExport(table, importPath, inPlace)
345 end
346end
347
348-- for clientside-only scripts you probably just want the AddCSLuaFile and resource features
349-- and that's what this does - fills in the other blanks for you
350local function exportClientServer(table)
351 table = table or {}
352 return export({
353 Realms = { "CLIENT" },
354 Resources = table.Resources or {},
355 Properties = {},
356 Hooks = {}
357 })
358end
359
360-- load all currently set entry points
361local function loadEntryPoints()
362 for k, v in pairs(EP_GlobalState.EntryPoints) do
363 local mod, new = import(k)
364 if new and mod.entry ~= nil then
365 mod.entry()
366 end
367
368 if new then
369 epDebugPrint("loaded entry point ", v)
370 end
371 end
372end
373
374-- the global import function that actually does the business
375-- inPlace: optional, see loadModule
376-- calledLocally: optional, should only be true if called from within this file
377-- returns: the module, and whether it was loaded or had already been loaded (true if newly loaded)
378function import(path, inPlace, calledLocally)
379 epDebugPrint("importing module " .. path)
380 local stackLevel = 1
381 if not calledLocally then
382 stackLevel = findCorrectStackLevel()
383 end
384
385 local currentFile = getCurrentFile(stackLevel)
386
387 -- we're probably testing from the console
388 if currentFile == nil then
389 currentFile = getCurrentFile(1)
390 end
391
392 epDebugPrint("current file: " .. currentFile)
393
394 -- locate path relative to the calling file
395 if not file.Exists(path, "LUA") then
396 local folder = string.GetPathFromFilename(currentFile)
397 path = simplifyPath(folder .. path)
398 end
399
400 local files, _ = file.Find(path, "LUA")
401
402 -- no files found
403 if files == nil or #files == 0 then
404 error("couldn't find module at path " .. path .. " relative to script " .. currentFile)
405 return nil, false
406 end
407
408 -- file found but it doesn't exist?
409 if not file.Exists(path, "LUA") then
410 error("couldn't find module at path " .. path)
411 end
412
413 epDebugPrint("found import path: ", path)
414
415 -- module already loaded, don't need to load it again
416 if EP_GlobalState.Modules[path] and not inPlace then
417 epDebugPrint("module already loaded")
418 return createModuleReference(path), false
419 end
420
421 -- setup new env to sandbox globals
422 local P = {}
423 setmetatable(P, { __index = _G })
424
425 -- run the file
426 local func = CompileFile(path)
427
428 if func == nil then
429 error("failed to compile module at path " .. path)
430 end
431
432 func = setfenv(func, P)
433 _G.export = export
434 _G.exportClientServer = exportClientServer
435
436 local success, res = pcall(func)
437
438 -- handle errors
439 if not success then
440 error("couldn't load module at path " .. path .. ": \'" .. res .. "\'")
441 return nil, false
442 elseif not res then
443 error("module " .. path .. "returned nil upon load. does it export anything?")
444 return nil, false
445 end
446
447 res = res(path, inPlace)
448
449 epDebugPrint("loaded module at path ", path)
450 return res, true
451end
452
453-- global function that declares the script this is called from as an entry point
454function declareEntryPoint()
455 if _G.export ~= nil then return epDebugPrint("declareEntryPoint called but we're currently importing this module") end
456 local file = getFirstDifferentFile()
457 if EP_GlobalState.EntryPoints[file] then return end
458 epDebugPrint("setting ", file, " as entry point")
459 EP_GlobalState.EntryPoints[file] = true
460 loadEntryPoints()
461end
462
463-- we've been refreshed! unload all modules
464if EP_GlobalState.DejaVu then
465 for p, mod in pairs(EP_GlobalState.Modules) do
466 unloadModule(p)
467 end
468end
469
470-- load all entry points that are already defined (which should only be defined if we've been refreshed)
471loadEntryPoints()
472
473EP_GlobalState.DejaVu = true
474
475-- our own custom auto-refresh
476if isDebug then
477 local function refreshUpdate()
478 -- we load new modules "in place" so we're not completely wiping out all the references
479 for path, mod in pairs(EP_GlobalState.Modules) do
480 local modifiedTime = file.Time(path, "LUA")
481 if modifiedTime > mod.LastModified then
482 MsgN("changes made to " .. path .. ", reloading")
483 unloadModule(path, true)
484 import(path, true, true)
485 end
486 end
487 end
488
489 timer.Create("GA_EP_RefreshTimer", 0.5, 0, refreshUpdate)
490
491 if SERVER then
492 util.AddNetworkString("GA_EP_Reload_Modules_Debug")
493 end
494
495 local function reloadModules()
496 local prevPrintSetting = _G.epDebugPrintEnable
497 _G.epDebugPrintEnable = true
498
499 for p, _ in pairs(EP_GlobalState.Modules) do
500 unloadModule(p)
501 end
502
503 loadEntryPoints()
504 _G.epDebugPrintEnable = prevPrintSetting
505 MsgN("refreshed")
506 end
507
508 concommand.Add("ga_ep_reload_modules", function(ply, cmd, args, argStr)
509 if not game.SinglePlayer() and not ply:IsAdmin() then return end
510 if not SERVER then return end
511
512 reloadModules()
513 net.Start("GA_EP_Reload_Modules_Debug")
514 net.Send(ply)
515 end)
516
517 net.Receive("GA_EP_Reload_Modules_Debug", function(len, ply)
518 reloadModules()
519 end)
520end