diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d5d0018 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "files.insertFinalNewline": true +} diff --git a/DependencyControl.json b/DependencyControl.json index 90690ce..eaa256b 100644 --- a/DependencyControl.json +++ b/DependencyControl.json @@ -32,28 +32,26 @@ "fileBaseUrl": "@{fileBaseUrl}macros-v@{version}-@{channel}/macros/@{namespace}", "channels": { "alpha": { - "version": "0.1.3", - "released": "2016-01-27", + "version": "0.2.0", + "released": "2026-05-23", "default": true, "files": [ { "name": ".moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "3677B2817C3D1FFE86981C8ABCC092B3D2CCEE7B" + "sha1": "5F7E0EEFC89E71F427819EEF69630455C0CC2304" } ], "requiredModules": [ { "moduleName": "l0.DependencyControl", - "version": "0.6.1" + "version": "0.7.0" } ] } }, "changelog": { - "0.1.0": [ - "initial release" - ], + "0.1.0": ["initial release"], "0.1.1": [ "The Install/Uninstall/Update dialogs now sort scripts by name.", "DependencyControl and its requirements no longer appear in the uninstall menu." @@ -63,6 +61,9 @@ ], "0.1.3": [ "Fixed an issue where trying to uninstall an unmanaged script resulted in an error unrelated to the intended error message." + ], + "0.2.0": [ + "Now registers the DepCtrl-internal test suite as a macro." ] } } @@ -76,44 +77,70 @@ "fileBaseUrl": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{scriptName}", "channels": { "alpha": { - "version": "0.6.3", - "released": "2016-02-06", + "version": "0.7.0", + "released": "2026-05-23", "default": true, "files": [ { "name": ".moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "76C22149258CB1189265A367C1B28046F54F8FB3" + "sha1": "36104C47B776412EBF36AAA00D583180BF4507D5" + }, + { + "name": "/Common.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "7262886AEB9F106E95697E86FF0D44738415DBA6" }, { "name": "/ConfigHandler.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "97BCD3207FE8158261FA7851057464535FCEFBC6" + "sha1": "1FEC3583C37E4A997E806D5B17A338390657BA53" }, { "name": "/FileOps.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "D999D34DB93BA76EF0E991CEB1CD63F5CC5F8E68" + "sha1": "5A54D4B942F34C005ABC977B7655C2B849EC8889" }, { "name": "/Logger.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "1E479FE95F0DFBEE8B098302AB589F32D0C40A00" + "sha1": "C4980A42A5AE9C8E24BE04DD12006D118606DBA1" + }, + { + "name": "/ModuleLoader.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "F35D88A9902FF9BC912D34299733D37FC15A36DF" + }, + { + "name": "/Record.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "796A430D14CACA3E2E15DBDD23F01DC4DC9E4B19" + }, + { + "name": "/SemanticVersioning.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "C8DE63A2BE75B1135CEED3ED4ADF7025C927706C" }, { "name": "/UnitTestSuite.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "ADAB6EFB05E08A7828DCA01BC1FC43D6482979A1" + "sha1": "BF316812E9ACF6C73570337C2FCA89FD33189A2B" }, { "name": "/UpdateFeed.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "1EE16D9D551FF82C2D7E448F2CD980E528874108" + "sha1": "7B64A01259AAA32E963708AE26BCF090AFC1E0DD" }, { "name": "/Updater.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "A4AE061724E68B2EFBB7495A477263E1746E228A" + "sha1": "6647D7CAB70637E2B961EF334153718B06EA1027" + }, + { + "name": ".moon", + "type": "test", + "url": "@{fileBaseUrl}/Tests.moon", + "sha1": "1ED8961CAFCADA7E4C04778227EECDC18E509B8D" } ], "requiredModules": [ @@ -141,6 +168,21 @@ } }, "changelog": { + "0.7.0": [ + "The previously monolithic `DependencyControl.moon` has been broken up into focused sub-modules as groundwork for a future SQLite-based script registry backend: `Record` (version record management), `ModuleLoader` (module loading and dependency resolution), `SemanticVersioning` (version number handling), and `Common` (shared enums and utilities).", + "Script types (automation macros vs. modules) and record types are now represented by proper enums (`ScriptType`, `RecordType`) instead of bare booleans, making the API more explicit and extensible.", + "UpdateFeed: Fixed two regressions caused by the refactoring, both of which caused the update process to fail.", + "Global initialization has been moved into a dedicated setup method, reducing implicit global state for loggers and configuration.", + "DepCtrl now refuses to load if the installed Moonscript is below the minimum required version with a helpful error message directing users to update their Aegisub build.", + "ModuleLoader: Fixed a regression where DepCtrl init hooks were called again on already-initialized modules, causing errors in modules that mutate their exported state on first call (e.g. BadMutex).", + "Common: Fixed a long-standing bug that guaranteed the `capitalize()` function to fail, that was never caught because it was unused until the refactoring.", + "Updater: Fixed a potential issue where a multi-assignment statement could corrupt record fields after an unsuccessful update." + ], + "0.6.4": [ + "Logger: Fixed a crash when `logEx()` is called without format arguments — `msg:format(...)` is now skipped when no varargs are supplied.", + "Logger: `fileBaseName` now falls back to `\"UNKNOWN\"` when `script_namespace` is nil, preventing errors during Logger initialization in contexts where no namespace is available.", + "Logger/UpdateFeed: Fixed chained method calls on file handles (`handle:write():flush()` and `handle:write():close()`) that could silently swallow errors" + ], "0.6.3": [ "Fixed a v0.6.2 regression that caused DependencyControl to fail loading the first time after a scheduled self-update." ], @@ -195,6 +237,35 @@ "The update feed format has been updated to v0.2.0 and introduces a new template variable to reference knownFeeds specifed at the top level." ] } + }, + "l0.dkjson": { + "url": "http://dkolf.de/dkjson-lua/", + "author": "David Kolf", + "name": "dkjson", + "description": "David Kolf's JSON module for Lua, vendored with and managed by DependencyControl.", + "fileBaseUrl": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{scriptName}", + "channels": { + "release": { + "version": "2.10.0", + "released": "2026-05-30", + "default": true, + "files": [ + { + "name": ".moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "94DDD16A2B34530F50F664F6D0F7A4B6B962A886" + }, + { + "name": "/vendor/dkjson.lua", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "A0597E1AEEB14D42DABB3A0E8C05129EB024EDCA" + } + ] + } + }, + "changelog": { + "2.10.0": ["Vendored dkjson v2.10 with a DependencyControl version record and json/dkjson self-registration."] + } } } } diff --git a/README.md b/README.md index b7d2834..7e465f0 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,29 @@ MyModule.version = version return version:register(MyModule) ``` + +##### Providing module aliases + +A module may declare additional names it can satisfy via a `provides` field. Once DependencyControl is loaded, any `require` for one of those names — including a bare, non-namespaced name — resolves to your module, *unless* a real module of that name is already available (yours is only a fallback). This lets a library stand in for a commonly-required dependency without every consuming script having to know your module's namespace. + +```lua +local version = DependencyControl{ + name = "dkjson", + version = "2.10.0", + moduleName = "l0.dkjson", + -- this module can satisfy `require("json")`: + provides = {"json"}, +} +``` + +Notes: + +* Each entry is a name string (or a table `{name = "json"}`, which may offer further customization options in the future). +* Provided names may be bare/non-namespaced even though your own `moduleName` must be a valid + (dotted) namespace. +* Resolution only applies after DependencyControl itself has been loaded, and always defers to a + genuinely installed module of that name — so users can still bring their own. + --------------------------------------------- ### Namespaces and Paths ### diff --git a/macros/l0.DependencyControl.Toolbox.moon b/macros/l0.DependencyControl.Toolbox.moon index d66c5f6..7e674ef 100644 --- a/macros/l0.DependencyControl.Toolbox.moon +++ b/macros/l0.DependencyControl.Toolbox.moon @@ -1,6 +1,6 @@ export script_name = "DependencyControl Toolbox" export script_description = "Provides DependencyControl maintenance and configuration tools." -export script_version = "0.1.3" +export script_version = "0.2.0" export script_author = "line0" export script_namespace = "l0.DependencyControl.Toolbox" @@ -11,11 +11,12 @@ logger.usePrefixWindow = false msgs = { install: { - scanning: "Scanning %d available feeds..." + scanning: "Scanning %d available feeds...", + createScriptUpdateRecordFailed: "Failed to create an update record for %s '%s' from feed %s: %s" } uninstall: { running: "Uninstalling %s '%s'..." - success: "%s '%s' was removed sucessfully. Reload your automation scripts or restart Aegisub for the changes to take effect." + success: "%s '%s' was removed successfully. Reload your automation scripts or restart Aegisub for the changes to take effect." lockedFiles: "%s Some script files are still in use and will be deleted during the next restart/reload:\n%s" error: "Error: %s" } @@ -37,7 +38,7 @@ buildInstalledDlgList = (scriptType, config, isUninstall) -> for namespace, script in pairs config.c[scriptType] continue if protectedModules[namespace] - item = "%s v%s%s"\format script.name, depRec\getVersionString(script.version), + item = "%s v%s%s"\format script.name, DepCtrl.SemanticVersioning\toString(script.version), script.activeChannel and " [#{script.activeChannel}]" or "" list[#list+1] = item table.sort list, (a, b) -> a\lower! < b\lower! @@ -77,12 +78,14 @@ getScriptListDlg = (macros, modules) -> {name: "module", class: "dropdown", x: 1, y: 1, width: 1, height: 1, items: modules, value: "" } } -runUpdaterTask = (scriptData, exhaustive) -> +runUpdaterTask = (scriptData, exhaustive, isInstall) -> return unless scriptData - task, err = DepCtrl.updater\addTask scriptData, nil, nil, exhaustive, scriptData.channel - if task then task\run! - else logger\log err - + + task, code, extErr = DepCtrl.updater\addTask scriptData, nil, nil, exhaustive, scriptData.channel + return task\run! if task + with scriptData + logger\log DepCtrl.updater\getUpdaterErrorMsg code, .moduleName or .name, + .moduleName and DepCtrl.ScriptType.Module or DepCtrl.ScriptType.Automation, isInstall, extErr -- Macros @@ -90,16 +93,22 @@ install = -> config = getConfig! addAvailableToInstall = (tbl, feed, scriptType) -> - for namespace, data in pairs feed.data[scriptType] - scriptData = feed\getScript namespace, scriptType == "modules", nil, false + scriptTypeConfigAndFeedKeyName = DepCtrl.ScriptType.name.legacy[scriptType] + + for namespace, data in pairs feed.data[scriptTypeConfigAndFeedKeyName] + scriptData, err = feed\getScript namespace, scriptType, nil, false + if err + logger\warn msgs.install.createScriptUpdateRecordFailed\format DepCtrl.terms.scriptType.singular[scriptType], namespace, feed.url, err + continue + channels, defaultChannel = scriptData\getChannels! tbl[namespace] or= {} for channel in *channels record = scriptData.data.channels[channel] - verNum = depRec\getVersionNumber record.version - unless config.c[scriptType][namespace] or (tbl[namespace][channel] and verNum < tbl[namespace][channel].verNum) + verNum = DepCtrl.SemanticVersioning\toNumber record.version + unless config.c[scriptTypeConfigAndFeedKeyName][namespace] or (tbl[namespace][channel] and verNum < tbl[namespace][channel].verNum) tbl[namespace][channel] = { name: scriptData.name, version: record.version, verNum: verNum, feed: feed.url, - default: defaultChannel == channel, moduleName: scriptType == "modules" and namespace } + default: defaultChannel == channel, moduleName: scriptType == DepCtrl.ScriptType.Module and namespace } return tbl buildDlgList = (tbl) -> @@ -120,8 +129,8 @@ install = -> logger\log msgs.install.scanning, #feeds for feed in *feeds - macros = addAvailableToInstall macros, feed, "macros" - modules = addAvailableToInstall modules, feed, "modules" + macros = addAvailableToInstall macros, feed, DepCtrl.ScriptType.Automation + modules = addAvailableToInstall modules, feed, DepCtrl.ScriptType.Module -- build macro and module lists as well as reverse mappings moduleList, moduleMap = buildDlgList modules @@ -132,8 +141,8 @@ install = -> -- create and run the update tasks macro, mdl = macroMap[res.macro], moduleMap[res.module] - runUpdaterTask mdl, false - runUpdaterTask macro, false + runUpdaterTask mdl, false, true + runUpdaterTask macro, false, true uninstall = -> doUninstall = (script) -> @@ -183,8 +192,8 @@ update = -> -- create and run the update tasks macro, mdl = macroMap[res.macro], moduleMap[res.module] - runUpdaterTask mdl, res.exhaustive - runUpdaterTask macro, res.exhaustive + runUpdaterTask mdl, res.exhaustive, false + runUpdaterTask macro, res.exhaustive, false macroConfig = -> config = getConfig "macros" @@ -213,9 +222,12 @@ macroConfig = -> config\write! +-- required to register DepCtrl test suite +DepCtrl.__class.version\register DepCtrl + depRec\registerMacros{ {"Install Script", "Installs an automation script or module on your system.", install}, {"Update Script", "Manually check and perform updates to any installed script.", update}, {"Uninstall Script", "Removes an automation script or module from your system.", uninstall}, {"Macro Configuration", "Lets you change per-automation script settings.", macroConfig}, -}, "DependencyControl" \ No newline at end of file +}, "DependencyControl" diff --git a/modules/AegisubShims.moon b/modules/AegisubShims.moon new file mode 100644 index 0000000..5ee0ce5 --- /dev/null +++ b/modules/AegisubShims.moon @@ -0,0 +1,3 @@ +aegisub = require "l0.AegisubShims.aegisub" + +return {:aegisub} diff --git a/modules/DependencyControl.moon b/modules/DependencyControl.moon index b7a95c4..90e1277 100644 --- a/modules/DependencyControl.moon +++ b/modules/DependencyControl.moon @@ -9,6 +9,14 @@ Update to a recent Aegisub build to resolve this issue. ]]\format MIN_MOONSCRIPT_VERSION, moonscript.version +-- Install the module-provides searcher and seed the bundled JSON provider before the +-- sub-modules below (which `require "json"`) load. The searcher resolves "json" to the +-- bundled l0.dkjson lazily — unless the user supplies their own "json", which the stock +-- searchers find first. +ModuleProvider = require "l0.DependencyControl.ModuleProvider" +ModuleProvider.install! +ModuleProvider.register name, "l0.dkjson" for name in *{"json", "dkjson"} + Logger = require "l0.DependencyControl.Logger" UpdateFeed = require "l0.DependencyControl.UpdateFeed" ConfigHandler = require "l0.DependencyControl.ConfigHandler" @@ -24,21 +32,21 @@ class DependencyControl extends Record @Updater = Updater @UnitTestSuite = UnitTestSuite @FileOps = FileOps + @SemanticVersioning = SemanticVersioning rec = DependencyControl{ name: "DependencyControl", - version: "0.6.3", + version: "0.7.0", description: "Provides script management and auto-updating for Aegisub macros and modules.", author: "line0", url: "http://github.com/TypesettingTools/DependencyControl", moduleName: "l0.DependencyControl", feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/DependencyControl.json", { - {"DM.DownloadManager", version: "0.3.1", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json"}, - {"BM.BadMutex", version: "0.1.3", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json"}, - {"PT.PreciseTimer", version: "0.1.5", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json"}, - {"requireffi.requireffi", version: "0.1.1", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json"}, + {"DM.DownloadManager", version: "0.3.1", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json", optional: true}, + {"BM.BadMutex", version: "0.1.3", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json", optional: true}, + {"requireffi.requireffi", version: "0.1.1", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json", optional: true}, } } DependencyControl.__class.version = rec @@ -46,4 +54,4 @@ LOADED_MODULES[rec.moduleName], package.loaded[rec.moduleName] = DependencyContr DependencyControl.updater\scheduleUpdate rec rec\requireModules! -return DependencyControl \ No newline at end of file +return DependencyControl diff --git a/modules/DependencyControl/Common.moon b/modules/DependencyControl/Common.moon index 8de8024..48ed94a 100644 --- a/modules/DependencyControl/Common.moon +++ b/modules/DependencyControl/Common.moon @@ -1,42 +1,198 @@ -ffi = require "ffi" - -class DependencyControlCommon - -- Some terms are shared across components - @platform = "#{ffi.os}-#{ffi.arch}" - - @terms = { - scriptType: { - singular: { "automation script", "module" } - plural: { "automation scripts", "modules" } - } - - isInstall: { - [true]: "installation" - [false]: "update" - } - - capitalize: (str) -> str[1]\upper! .. str\sub 2 - } - - -- Common enums - @RecordType = { - Managed: 1 - Unmanaged: 2 - } - - @ScriptType = { - Automation: 1 - Module: 2 - name: { - legacy: { "macros", "modules" } - canonical: {"automation", "modules"} - } - } - - automationDir: { - aegisub.decode_path("?user/automation/autoload"), - aegisub.decode_path("?user/automation/include") - } - - @testDir = {aegisub.decode_path("?user/automation/tests/DepUnit/macros"), - aegisub.decode_path("?user/automation/tests/DepUnit/modules")} \ No newline at end of file +ffi = require "ffi" + +-- Compares two values for deep equality. Tables are compared recursively; +-- other types use == except that two identical values always compare equal. +-- Circular references are handled. +_equals = (a, b, aType, bType) -> + treeA, treeB, depth = {}, {}, 0 + + recurse = (a, b, aType = type a, bType) -> + return true if a == b + bType or= type b + return false if aType != bType or aType != "table" + + return false if #a != #b + + aFieldCnt, bFieldCnt = 0, 0 + local tablesSeenAtKeys + + depth += 1 + treeA[depth], treeB[depth] = a, b + + for k, v in pairs a + vType = type v + if vType == "table" + tablesSeenAtKeys or= {} + tablesSeenAtKeys[k] = true + + for i = 1, depth + return true if v == treeA[i] and b[k] == treeB[i] + + unless recurse v, b[k], vType + depth -= 1 + return false + + aFieldCnt += 1 + + for k, v in pairs b + continue if tablesSeenAtKeys and tablesSeenAtKeys[k] + if bFieldCnt == aFieldCnt or not recurse v, a[k] + depth -= 1 + return false + bFieldCnt += 1 + + res = recurse getmetatable(a), getmetatable b + depth -= 1 + return res + + return recurse a, b, aType, bType + +-- Compares table items for equality ignoring keys. +-- Delegates table-vs-table comparisons to _equals. +_itemsEqual = (a, b, onlyNumKeys = true, ignoreExtraAItems, requireIdenticalItems) -> + seen, aTbls = {}, {} + aCnt, aTblCnt, bCnt = 0, 0, 0 + + findEqualTable = (bTbl) -> + for i, aTbl in ipairs aTbls + if _equals aTbl, bTbl + table.remove aTbls, i + seen[aTbl] = nil + return true + return false + + if onlyNumKeys + aCnt, bCnt = #a, #b + return false if not ignoreExtraAItems and aCnt != bCnt + + for v in *a + seen[v] = true + if "table" == type v + aTblCnt += 1 + aTbls[aTblCnt] = v + + for v in *b + if seen[v] + seen[v] = nil + continue + + if type(v) != "table" or requireIdenticalItems or not findEqualTable v + return false + + else + for _, v in pairs a + aCnt += 1 + seen[v] = true + if "table" == type v + aTblCnt += 1 + aTbls[aTblCnt] = v + + for _, v in pairs b + bCnt += 1 + if seen[v] + seen[v] = nil + continue + + if type(v) != "table" or requireIdenticalItems or not findEqualTable v + return false + + return false if not ignoreExtraAItems and aCnt != bCnt + + return true + +--- Shared constants, enums, and terminology used across DependencyControl modules. +-- @class DependencyControlCommon +class DependencyControlCommon + msgs = { + validateNamespace: { + badNamespace: "Namespace '%s' failed validation. Namespace rules: must contain 1+ single dots, but not start or end with a dot; all other characters must be in [A-Za-z0-9-_]." + } + } + -- Some terms are shared across components + @platform = "#{ffi.os}-#{ffi.arch}" + + @moduleName = "l0.DependencyControl" + + @terms = { + scriptType: { + singular: { "automation script", "module" } + plural: { "automation scripts", "modules" } + } + + isInstall: { + [true]: "installation" + [false]: "update" + } + + capitalize: (str) -> (str\sub 1, 1)\upper! .. str\sub 2 + } + + -- Common enums + @RecordType = { + Managed: 1 + Unmanaged: 2 + } + + @ScriptType = { + Automation: 1 + Module: 2 + name: { + legacy: { "macros", "modules" } + canonical: {"automation", "modules"} + } + } + + --- Validates a DependencyControl namespace string. + -- @param namespace string + -- @return boolean|nil + -- @return string|nil err + @validateNamespace = (namespace) -> + segments = [seg for seg in namespace\gmatch "[^%.]+"] + _, dotCount = namespace\gsub "%.", "" + if #segments >= 2 and dotCount == #segments - 1 and not namespace\match "[^-._%w]" + return true + return false, msgs.validateNamespace.badNamespace\format namespace + + automationDir: { + aegisub.decode_path("?user/automation/autoload"), + aegisub.decode_path("?user/automation/include") + } + + @testDir = {aegisub.decode_path("?user/automation/tests/DepUnit/macros"), + aegisub.decode_path("?user/automation/tests/DepUnit/modules")} + + --- Deep equality comparison. Tables compared recursively; other types use ==. + -- Circular references are handled. Metatables are included in the comparison. + -- @static + -- @param a + -- @param b + -- @treturn boolean + @equals = _equals + + --- Compares table items for equality, ignoring keys. + -- By default only numerical indexes are compared. + -- @static + -- @tparam table a + -- @tparam table b + -- @tparam[opt=true] boolean onlyNumKeys + -- @tparam[opt=false] boolean ignoreExtraAItems + -- @tparam[opt=false] boolean requireIdenticalItems + -- @treturn boolean + @itemsEqual = _itemsEqual + + --- Shallow-copies a table (no metatable). + -- @static + -- @param tbl table the table to copy + -- @return table the copied table + @copy = (tbl) -> {k, v for k, v in pairs tbl} + + --- Deep-copies a table recursively (no metatables). + -- @param tbl table the table to deep copy + -- @return table the deep-copied table + deepCopy = (tbl) -> {k, (type(v) == "table" and deepCopy(v) or v) for k, v in pairs tbl} + + --- Deep-copies a table recursively (no metatables). + -- @static + -- @param tbl table the table to deep copy + -- @return table the deep-copied table + @deepCopy = deepCopy diff --git a/modules/DependencyControl/ConfigHandler.moon b/modules/DependencyControl/ConfigHandler.moon index 11cfacf..c922fd4 100644 --- a/modules/DependencyControl/ConfigHandler.moon +++ b/modules/DependencyControl/ConfigHandler.moon @@ -1,330 +1,435 @@ -util = require "aegisub.util" json = require "json" -PreciseTimer = require "PT.PreciseTimer" -mutex = require "BM.BadMutex" -fileOps = require "l0.DependencyControl.FileOps" -Logger = require "l0.DependencyControl.Logger" +fileOps = require "l0.DependencyControl.FileOps" +Logger = require "l0.DependencyControl.Logger" +Lock = require "l0.DependencyControl.Lock" +ConfigView = require "l0.DependencyControl.ConfigView" +--- JSON-backed configuration manager with cooperative cross-script locking. +-- Manages one JSON file per instance. Use ConfigView (via getView or ConfigView.get) +-- to access specific hives (nested sections) of the config. +-- @class ConfigHandler class ConfigHandler - @handlers = {} - errors = { - jsonDecode: "JSON parse error: %s" - configCorrupted: [[An error occured while parsing the JSON config file. + msgs = { + get: { + failedLoad: "Could not provide a ConfigHandler because there was an issue loading the configuration file: %s" + failedCreate: "Failed to create ConfigHandler for file '%s': %s" + } + getHive: { + unexpected: "An unexpected error occurred while trying to create hive '%s' on ConfigHandler for file '%s'" + } + getOverlappingViews: { + differentHandler: "Other view on config file '%s' does not belong to this config handler of config file '%s'." + } + getView: { + failedView: "Failed to get #{ConfigView.__name} '%s' on ConfigHandler for file '%s': %s" + failedHandler: "Failed to get ConfigHandler for file '%s' while trying to acquire a view on #{ConfigView.__name}: %s" + } + mergeHive: { + badKey: "Can't merge hive because the path key #%d (%s) points to a %s." + } + new: { + badPath: "Couldn't validate specified config file path '%s': %s" + failedLoad: "Failed to load config file '%s': %s" + } + readFile: { + failedLock: "Failed to lock config file for reading: %s" + fileNotFound: "Couldn't find config file '%s'." + jsonDecodeError: "JSON parse error: %s" + configCorrupted: [[An error occurred while parsing the JSON config file. A backup of the corrupted configuration has been written to '%s'. Reload your automation scripts to generate a new configuration file.]] - badKey: "Can't %s section because the key #%d (%s) leads to a %s." - jsonRoot: "JSON root element must be an array or a hashtable, got a %s." - noFile: "No config file defined." - failedLock: "Failed to lock config file for %s: %s" - waitLockFailed: "Error waiting for existing lock to be released: %s" - forceReleaseFailed: "Failed to force-release existing lock after timeout had passed (%s)" - noLock: "#{@@__name} doesn't have a lock" - writeFailedRead: "Failed reading config file: %s." - lockTimeout: "Timeout reached while waiting for write lock." - } - traceMsgs = { - -- waitingLockPre: "Waiting %d ms before trying to get a lock..." - waitingLock: "Waiting for config file lock to be released (%d ms passed)... " - waitingLockFinished: "Lock was released after %d ms." - mergeSectionStart: "Merging own section into configuration. Own Section: %s\nConfiguration: %s" - mergeSectionResult: "Merge completed with result: %s" - fileNotFound: "Couldn't find config file '%s'." - fileCreate: "Config file '%s' doesn't exist, yet. Will write a fresh copy containing the current configuration section." - writing: "Writing config file '%s'..." - -- waitingLockTimeout: "Timeout was reached after %d seconds, force-releasing lock..." + failedHandle: "Failed to acquire a handle for reading the config file: %s" + badJsonRoot: "JSON root element must be an array or a hashtable, got a %s." + } + load: { + noFilePath: "Can't load because no config file is set." + noFile: "Starting with a fresh config because the config file '%s' is missing (%s)..." + } + save: { + failedWhole: "Failed to save complete config to file '%s': %s" + failedHives: "Failed to save hives %s into config file '%s': %s" + failedMerge: "Failed to merge config hive %s into file '%s': %s" + failedClean: "Failed to clean config hive %s in file '%s': %s" + failedLock: "Failed to lock config file for saving: %s" + failedRead: "Failed to read config file '%s': %s." + noFile: "Can't save because no config file is set." + fileCreate: "Config file '%s' doesn't exist, will write a fresh one..." + } + traverseHive: { + badKey: "Can't retrieve hive because the path key #%d (%s) points to a %s." + } + writeFile: { + writing: "Writing config file '%s'..." + failedLock: "Failed to lock config file for writing: %s" + failedSerialize: "Failed to serialize configuration to JSON: %s" + failedHandle: "Failed to acquire a handle for writing the config file: %s" + } } - new: (@file, defaults, @section, noLoad, @logger = Logger fileBaseName: @@__name) => - @section = {@section} if "table" != type @section - @defaults = defaults and util.deep_copy(defaults) or {} - -- register all handlers for concerted writing - @setFile @file - - -- set up user configuration and make defaults accessible - @userConfig = {} - @config = setmetatable {}, { - __index: (_, k) -> - if @userConfig and @userConfig[k] ~= nil - return @userConfig[k] - else return @defaults[k] - __newindex: (_, k, v) -> - @userConfig or= {} - @userConfig[k] = v - __len: (tbl) -> return 0 - __ipairs: (tbl) -> error "numerically indexed config hive keys are not supported" - __pairs: (tbl) -> - merged = util.copy @defaults - merged[k] = v for k, v in pairs @userConfig - return next, merged - } - @c = @config -- shortcut - - -- rig defaults in a way that writing to contained tables deep-copies the whole default - -- into the user configuration and sets the requested property there - recurse = (tbl) -> - for k,v in pairs tbl - continue if type(v)~="table" or type(k)=="string" and k\match "^__" - -- replace every table reference with an empty proxy table - -- this ensures all writes to the table get intercepted - tbl[k] = setmetatable {__key: k, __parent: tbl, __tbl: v}, { - -- make the original table the index of the proxy so that defaults can be read - __index: v - __len: (tbl) -> return #tbl.__tbl - __newindex: (tbl, k, v) -> - upKeys, parent = {}, tbl.__parent - -- trace back to defaults entry, pick up the keys along the path - while parent.__parent - tbl = parent - upKeys[#upKeys+1] = tbl.__key - parent = tbl.__parent - - -- deep copy the whole defaults node into the user configuration - -- (util.deep_copy does not copy attached metatable references) - -- make sure we copy the actual table, not the proxy - @userConfig or= {} - @userConfig[tbl.__key] = util.deep_copy @defaults[tbl.__key].__tbl - -- finally perform requested write on userdata - tbl = @userConfig[tbl.__key] - for i = #upKeys-1, 1, -1 - tbl = tbl[upKeys[i]] - tbl[k] = v - __pairs: (tbl) -> return next, tbl.__tbl - __ipairs: (tbl) -> - i, n, orgTbl = 0, #tbl.__tbl, tbl.__tbl - -> - i += 1 - return i, orgTbl[i] if i <= n - } - recurse tbl[k] - - recurse @defaults - @load! unless noLoad - - setFile: (path) => - return false unless path - if @@handlers[path] - table.insert @@handlers[path], @ - else @@handlers[path] = {@} - path, err = fileOps.validateFullPath path, true - return nil, err unless path - @file = path - return true + -- make references to provided handlers weak to allow for gc + @handlers = setmetatable {}, {__mode: 'v'} + @logger = Logger fileBaseName: "DepCtrl.ConfigHandler", fileSubName: script_namespace - unsetFile: => - handlers = @@handlers[@file] - if handlers and #handlers>1 - @@handlers[@file] = [handler for handler in *handlers when handler != @] - else @@handlers[@file] = nil - @file = nil - return true + --- Returns an existing handler for filePath, or creates and optionally loads one. + -- @param filePath string + -- @param[opt] logger Logger + -- @param[opt=false] noLoad boolean + -- @return ConfigHandler|nil + -- @return string|nil err + @get = (filePath, logger = @logger, noLoad = false) => + return handler for path, handler in pairs @@handlers when path == filePath - readFile: (file = @file, useLock = true, waitLockTime) => - if useLock - time, err = @getLock waitLockTime - unless time - -- handle\close! - return false, errors.failedLock\format "reading", err + path, msg = fileOps.validateFullPath filePath, true + return nil, msgs.new.badPath\format filePath, msg unless path + + success, handler = pcall ConfigHandler, path, logger + unless success + return nil, msgs.get.failedCreate\format filePath, handler + + @@handlers[path] = handler + + unless noLoad + success, msg = handler\load! + return nil, msgs.get.failedLoad\format filePath, msg unless success + + return handler + + + --- Returns a ConfigView for the given file and hive path, creating a handler if needed. + -- @param filePath string + -- @param hivePath string|string[] + -- @param[opt] defaults table + -- @param[opt] logger Logger + -- @return ConfigView|nil + -- @return string|nil err + @getView = (filePath, hivePath, defaults, logger) => + handler, msg = @get filePath, logger + return nil, msgs.getView.failedHandler\format filePath, msg unless handler + + return handler\getView hivePath, defaults - mode, file = fileOps.attributes file, "mode" + + --- Creates a ConfigHandler for the given file. Does not load from disk. + -- @param[opt] filePath string + -- @param[opt] logger Logger + new: (filePath, @logger = Logger fileBaseName: @@__name) => + @views = setmetatable {}, {__mode: 'k'} + @config = {} + if filePath + path, msg = fileOps.validateFullPath filePath, true + @logger\assert path, msgs.new.badPath, filePath, msg + @filePath = path + @lock = Lock namespace: "l0.DependencyControl.ConfigHandler", resource: @filePath, + holderName: @@__name, logger: @logger + + + readFile = (waitLockTime, useLock = true) => + mode, file = fileOps.attributes @filePath, "mode" if mode == nil - @releaseLock! if useLock - return false, file + return nil, file + elseif not mode - @releaseLock! if useLock - @logger\trace traceMsgs.fileNotFound, @file - return nil + @logger\trace msgs.readFile.fileNotFound, @filePath + return false, msgs.readFile.fileNotFound\format @filePath + + if useLock + lockState, msg = @lock\lock waitLockTime + if lockState != Lock.LockState.Held + return nil, msgs.readFile.failedLock\format msg - handle, err = io.open file, "r" + handle, msg = io.open file, "r" unless handle - @releaseLock! if useLock - return false, err + @lock\release! if useLock + return nil, msgs.readFile.failedHandle\format msg data = handle\read "*a" - success, result = pcall json.decode, data + handle\close! + + @lock\release! if useLock + + success, res = pcall json.decode, data unless success - handle\close! -- JSON parse error usually points to a corrupted config file -- Rename the broken file to allow generating a new one - -- so the user can continue his work - @logger\trace errors.jsonDecode, result - backup = @file .. ".corrupted" - fileOps.copy @file, backup - fileOps.remove @file, false, true + -- so the user can continue their work + @logger\debug msgs.readFile.jsonDecodeError, res + backup = @filePath .. ".corrupted" + fileOps.copy @filePath, backup + fileOps.remove @filePath, false, true + + @logger\warn msgs.readFile.configCorrupted, backup + return false, msgs.readFile.configCorrupted\format backup + + if "table" != type res + return nil, msgs.readFile.badJsonRoot\format type res + + return res - @releaseLock! if useLock - return false, errors.configCorrupted\format backup + writeFile = (config, waitLockTime, haveLock = false) => + success, res = pcall json.encode, ConfigHandler\getSerializableCopy config + unless success + return nil, msgs.writeFile.failedSerialize\format res + + unless haveLock + lockState, msg = @lock\lock waitLockTime + if lockState != Lock.LockState.Held + return nil, msgs.writeFile.failedLock\format msg + + handle, msg = io.open(@filePath, "w") + unless handle + @lock\release! unless haveLock + return nil, msgs.writeFile.failedHandle\format msg + + @logger\trace msgs.writeFile.writing, @filePath + handle\setvbuf "full", 10e6 + handle\write res + handle\flush! handle\close! - @releaseLock! if useLock - if "table" != type result - return false, errors.jsonRoot\format type result + @lock\release! unless haveLock + return true + + + hasNonPrivateFields = (tbl) -> + for k, _ in pairs tbl + if k\sub(1, 1) == "_" + continue + else return true + + return false + - return result + makeHive = (path, config) -> + return config if #path == 0 + recurse = (path, hive, depth, config) -> + return if depth > #path + hive[path[depth]] = depth == #path and config or {} + return recurse path, hive[path[depth]], depth + 1, config - load: => - return false, errors.noFile unless @file + hive = {} + recurse path, hive, 1, config + return hive - config, err = @readFile! - return config, err unless config - sectionExists = true - for i=1, #@section - config = config[@section[i]] + traverseHive = (path, config, depth = #path) -> + for i, key in ipairs path + break if i > depth switch type config - when "table" continue when "nil" - config, sectionExists = {}, false - break - else return false, errors.badKey\format "retrive", i, tostring(@section[i]),type config - - @userConfig or= {} - @userConfig[k] = v for k,v in pairs config - return sectionExists - - mergeSection: (config) => - --@logger\trace traceMsgs.mergeSectionStart, @logger\dumpToString(@section), - -- @logger\dumpToString config - - section, sectionExists = config, true - -- create missing parent sections - for i=1, #@section - childSection = section[@section[i]] - if childSection == nil - -- don't create parent sections if this section is going to be deleted - unless @userConfig - sectionExists = false - break - section[@section[i]] = {} - childSection = section[@section[i]] - elseif "table" != type childSection - return false, errors.badKey\format "update", i, tostring(@section[i]),type childSection - section = childSection if @userConfig or i < #@section - -- merge our values into our section - if @userConfig - section[k] = v for k,v in pairs @userConfig - elseif sectionExists - section[@section[#@section]] = nil - - -- @logger\trace traceMsgs.mergeSectionResult, @logger\dumpToString config - return config - - delete: (concertWrite, waitLockTime) => - @userConfig = nil - return @write concertWrite, waitLockTime - - write: (concertWrite, waitLockTime) => - return false, errors.noFile unless @file + return false + when "table" + config = config[key] + else + return nil, msgs.traverseHive.badKey\format i, key, type config - -- get a lock to avoid concurrent config file access - time, err = @getLock waitLockTime - unless time - return false, errors.failedLock\format "writing", err + return config or false - -- read the config file - config, err = @readFile @file, false - if config == false - @releaseLock! - return false, errors.writeFailedRead\format err - @logger\trace traceMsgs.fileCreate, @file unless config - config or= {} - -- merge in our section - -- concerted writing allows us to update a configuration file - -- shared by multiple handlers in the lua environment - handlers = concertWrite and @@handlers[@file] or {@} - for handler in *handlers - config, err = handler\mergeSection config - unless config - @releaseLock! - return false, err - - -- create JSON - success, res = pcall json.encode, config - unless success - @releaseLock! - return false, res + mergeHive = (path, source, target, depth = 1) -> + -- merging in a root hive overwrites target with source + if #path == 0 + target[k] = nil for k, _ in pairs target + target[k] = source[k] for k, _ in pairs source + return true - -- write the whole config file in one go - handle, err = io.open(@file, "w") - unless handle - @releaseLock! - return false, err + key = path[depth] - @logger\trace traceMsgs.writing, @file - handle\setvbuf "full", 10e6 - handle\write res - handle\flush! - handle\close! - @releaseLock! + if depth == #path + target[key] = source[key] + return true + + if target[key] != nil and "table" != type target[key] + return nil, msgs.mergeHive.badKey\format depth, key, type target[key] + + target[key] or= {} + return mergeHive path, source[key], target[key], depth + 1 + + + purgeHive = (path, config) -> + if #path == 0 + config[k] = nil for k, _ in pairs config + + for i = #path, 1, -1 + parent, msg = traverseHive path, config, i-1 + switch parent + when nil then return nil, msg + when false then continue + + parent[path[i]] = nil + break if hasNonPrivateFields parent return true - getLock: (waitTimeout = 5000, checkInterval = 50) => - return 0 if @hasLock - success = mutex.tryLock! - if success - @hasLock = true - return 0 - - timeout, timePassed = waitTimeout, 0 - while not success and timeout > 0 - PreciseTimer.sleep checkInterval - success = mutex.tryLock! - timeout -= checkInterval - timePassed = waitTimeout - timeout - if timePassed % (checkInterval*5) == 0 - @logger\trace traceMsgs.waitingLock, timePassed - - if success - @logger\trace traceMsgs.waitingLockFinished, timePassed - @hasLock = true - return timePassed - else - -- @logger\trace traceMsgs.waitingLockTimeout, waitTimeout/1000 - -- success, err = @releaseLock true - -- unless success - -- return false, errors.forceReleaseFailed\format err - -- @hasLock = true - --return waitTimeout - return false, errors.lockTimeout - - getSectionHandler: (section, defaults, noLoad) => - return @@ @file, defaults, section, noLoad, @logger - - releaseLock: (force) => - if @hasLock or force - @hasLock = false - mutex.unlock! - return true - return false, errors.noLock + cleanHive = (path, config) -> + hive, msg = traverseHive path, config + return hive, msg if hive == nil + return true if hive == false -- path absent in file config; nothing to purge + + return false if hasNonPrivateFields hive + return purgeHive path, config + + + --- Deep-copies a value while skipping private keys prefixed with "_". + -- @param val any + -- @return any -- copied from Aegisub util.moon, adjusted to skip private keys - deepCopy: (tbl) => + @getSerializableCopy = (val) => seen = {} copy = (val) -> return val if type(val) != 'table' - return seen[val] if seen[val] + return {} if seen[val] -- nuke circular references which JSON doesn't support seen[val] = val {k, copy(v) for k, v in pairs val when type(k) != "string" or k\sub(1,1) != "_"} - copy tbl - - import: (tbl = {}, keys, updateOnly, skipSameLengthTables) => - tbl = tbl.userConfig if tbl.__class == @@ - changesMade = false - @userConfig or= {} - keys = {key, true for key in *keys} if keys - - for k,v in pairs tbl - continue if keys and not keys[k] or @userConfig[k] == v - continue if updateOnly and @c[k] == nil - -- TODO: deep-compare tables - isTable = type(v) == "table" - if isTable and skipSameLengthTables and type(@userConfig[k]) == "table" and #v == #@userConfig[k] - continue - continue if type(k) == "string" and k\sub(1,1) == "_" - @userConfig[k] = isTable and @deepCopy(v) or v - changesMade = true + copy val + + + --- Returns the config table at the given hive path, creating it if missing. + -- @param path string[] + -- @return table|nil hive + -- @return string|nil err + getHive: (path) => + hive, msg = traverseHive path, @config + switch hive + when nil + return nil, msg + when false + res, msg = mergeHive path, makeHive(path), @config + return nil, msg unless res + + hive, msg = traverseHive path, @config + unless hive + @logger\warn msgs.getHive.unexpected, path, @filePath + return nil, msgs.getHive.unexpected\format path, @filePath + + return hive + + + --- Returns views on the same handler whose hive paths overlap with targetView. + -- @param targetView ConfigView + -- @return ConfigView[]|nil + -- @return string|nil err + getOverlappingViews: (targetView) => + if targetView.__configHandler != @ + return nil, msgs.getOverlappingViews.differentHandler\format targetView.__configHandler.filePath, @filePath + + return for view, _ in pairs @views + continue if view == targetView or not targetView\isOverlappingView view + view + + + --- Creates and registers a ConfigView for the given hive path. + -- @param hivePath string|string[] + -- @param[opt] defaults table + -- @return ConfigView|nil + -- @return string|nil err + getView: (hivePath, defaults) => + success, view = pcall ConfigView, @, hivePath, defaults + + unless success + return nil, msgs.getView.failedView\format hivePath, @filePath, view + + @views[view] = true + return view + + + --- Reads the config file and refreshes the in-memory config and all (or specified) views. + -- @param[opt] views ConfigView|ConfigView[] + -- @param[opt] waitLockTime number + -- @return boolean|nil + -- @return string|nil err + load: (views, waitLockTime) => + return nil, msgs.load.noFilePath unless @filePath + if type(views) == "table" and views.__class == ConfigView + views = {views} + + config, msg = readFile @, waitLockTime + return nil, msg if config == nil + + @logger\debug msgs.load.noFile, @filePath, msg unless config + -- config file may not yet exist or have been reset due to corruption + config or= {} + + if views == nil or @config == nil + @config = config + view\refresh! for view, _ in pairs @views + return true + + viewsToRefresh = {view, true for view in *views} + + for view in *views + hiveConfig, msg = traverseHive view.__hivePath, config + switch hiveConfig + when nil + return nil, msg + when false + mergeHive view.__hivePath, makeHive(view.__hivePath), @config + else mergeHive view.__hivePath, makeHive(view.__hivePath, hiveConfig), @config + + viewsToRefresh[v] or= true for v in *@getOverlappingViews view + + view\refresh! for view, _ in pairs viewsToRefresh + + return true + + + --- Writes the config file, merging only the specified views (or the full config if nil). + -- @param[opt] views ConfigView|ConfigView[] + -- @param[opt] waitLockTime number + -- @return boolean|nil + -- @return string|nil err + save: (views, waitLockTime) => + return nil, msgs.save.noFile unless @filePath + if type(views) == "table" and views.__class == ConfigView + views = {views} + + -- get a lock to avoid concurrent config file access + lockState, msg = @lock\lock waitLockTime + if lockState != Lock.LockState.Held + return nil, msgs.save.failedLock\format msg + + -- read the config file + config, err = readFile @ + if config == nil + @lock\release! + return nil, msgs.save.failedRead\format @filePath, err + + @logger\trace msgs.save.fileCreate, @filePath unless config + config or= {} - return changesMade \ No newline at end of file + -- save the whole config file if desired + if views == nil + success, msg = writeFile @, @config, nil, true + @lock\release! + return if success + true + else nil, msgs.save.failedWhole\format @filePath, msg + + -- otherwise only merge in the specified views + for view in *views + success, msg = mergeHive view.__hivePath, @config, config + unless success + @lock\release! + return nil, msgs.save.failedMerge\format view.__hivePath, @filePath, msg + + success, msg = cleanHive view.__hivePath, config + if success == nil + @lock\release! + return nil, msgs.save.failedClean\format view.__hivePath, @filePath, msg + + success, msg = writeFile @, config, nil, true + @lock\release! + return if success + true + else nil, msgs.save.failedHives\format views, @filePath, msg + + + --- Removes a view's hive from the in-memory config and returns the fresh (empty) hive. + -- @param hive ConfigView + -- @return table|nil + -- @return string|nil err + purgeHive: (hive) => + purgeHive hive.__hivePath, @config + return @getHive hive.__hivePath diff --git a/modules/DependencyControl/ConfigView.moon b/modules/DependencyControl/ConfigView.moon new file mode 100644 index 0000000..3b4f926 --- /dev/null +++ b/modules/DependencyControl/ConfigView.moon @@ -0,0 +1,228 @@ +Common = require "l0.DependencyControl.Common" +local ConfigHandler + +--- A view into a hive (nested path) of a ConfigHandler's JSON config file. +-- Holds the proxy/defaults machinery and exposes @c / @config / @userConfig. +-- Multiple views on the same file are coordinated through their shared ConfigHandler. +-- @class ConfigView +class ConfigView + msgs = { + new: { + failedRetrieveHive: "Failed to retrieve hive %s from ConfigHandler: %s" + } + isOverlappingView: { + differentHandler: "Other view on config file '%s' does not belong to the same config handler as this view on config file '%s'." + } + } + + --- Returns a ConfigView for the given file and hive path, creating a handler if needed. + -- @param filePath string|boolean + -- @param hivePath string|string[] + -- @param[opt] defaults table + -- @param[opt] logger Logger + -- @param[opt=false] noLoad boolean + -- @return ConfigView|nil + -- @return string|nil err + @get = (filePath, hivePath, defaults, logger, noLoad = false) => + ConfigHandler or= require "l0.DependencyControl.ConfigHandler" + + if filePath + handler, msg = ConfigHandler\get filePath, logger, noLoad + return nil, msg unless handler + return handler\getView hivePath, defaults + else + -- orphan view: in-memory only, no file backing (used for virtual modules) + handler = ConfigHandler nil, logger + return ConfigView handler, hivePath, defaults + + + --- Creates a view into a hive of the given ConfigHandler. + -- @param configHandler ConfigHandler|nil + -- @param hivePath string|string[] + -- @param[opt] defaults table + new: (configHandler, hivePath, defaults) => + ConfigHandler or= require "l0.DependencyControl.ConfigHandler" + @__hivePath = "table" == type(hivePath) and hivePath or {hivePath} + @__configHandler = configHandler + + -- deprecated, provided for compatibility with DepCtrl < 0.7 + @section = @__hivePath + -- compat: expose file path directly on the view + @file = configHandler and configHandler.filePath + + if configHandler + success, msg = @refresh! + configHandler.logger\assert @userConfig, msgs.new.failedRetrieveHive, hivePath, msg + else + @userConfig = {} -- orphan view: no file backing + + setDefaults @, defaults + @config = setmetatable {}, { + __index: (_, k) -> + if @userConfig[k] ~= nil + return @userConfig[k] + else return @defaults[k] + __newindex: (_, k, v) -> + @userConfig[k] = v + __len: (tbl) -> return 0 + __ipairs: (tbl) -> error "numerically indexed config hive keys are not supported" + __pairs: (tbl) -> + merged = Common.copy @defaults + merged[k] = v for k, v in pairs @userConfig + return next, merged + } + @c = @config -- shortcut + + + setDefaults = (defaults) => + @defaults = defaults and Common.deepCopy(defaults) or {} + -- rig defaults in a way that writing to contained tables deep-copies the whole default + -- into the user configuration and sets the requested property there + recurse = (tbl) -> + for k,v in pairs tbl + continue if type(v)~="table" or type(k)=="string" and k\match "^__" + -- replace every table reference with an empty proxy table + -- this ensures all writes to the table get intercepted + tbl[k] = setmetatable {__targetMethodKey: k, __parent: tbl, __targetTable: v}, { + -- make the original table the index of the proxy so that defaults can be read + __index: v + __len: (tbl) -> return #tbl.__targetTable + __newindex: (tbl, k, v) -> + upKeys, parent = {}, tbl.__parent + -- trace back to defaults entry, pick up the keys along the path + while parent.__parent + tbl = parent + upKeys[#upKeys+1] = tbl.__targetMethodKey + parent = tbl.__parent + + -- deep copy the whole defaults node into the user configuration + -- (util.deep_copy does not copy attached metatable references) + -- make sure we copy the actual table, not the proxy + @userConfig[tbl.__targetMethodKey] = Common.deepCopy @defaults[tbl.__targetMethodKey].__targetTable + -- finally perform requested write on userdata + tbl = @userConfig[tbl.__targetMethodKey] + for i = #upKeys-1, 1, -1 + tbl = tbl[upKeys[i]] + tbl[k] = v + __pairs: (tbl) -> return next, tbl.__targetTable + __ipairs: (tbl) -> + i, n, orgTbl = 0, #tbl.__targetTable, tbl.__targetTable + -> + i += 1 + return i, orgTbl[i] if i <= n + } + recurse tbl[k] + + recurse @defaults + + + --- Removes this view's hive from the config file. + -- @param[opt] waitLockTime number + -- @return boolean|nil + -- @return string|nil err + delete: (waitLockTime) => + @userConfig, msg = @__configHandler\purgeHive @ + return nil, msg unless @userConfig + return @save waitLockTime + + + --- Copies values from a table or ConfigView into this view's user config. + -- @param[opt] tbl table|ConfigView + -- @param[opt] keys string[] + -- @param[opt] updateOnly boolean + -- @param[opt] skipSameLengthTables boolean + -- @return boolean changesMade + import: (tbl, keys, updateOnly, skipSameLengthTables) => + tbl = tbl.userConfig if tbl.__class == @@ + changesMade = false + keySet = {key, true for key in *keys} if keys + + for k, v in pairs tbl + continue if keys and not keySet[k] or @userConfig[k] == v + continue if updateOnly and @config[k] == nil + isTable = type(v) == "table" + if isTable and skipSameLengthTables and type(@userConfig[k]) == "table" and #v == #@userConfig[k] + continue + continue if type(k) == "string" and k\sub(1,1) == "_" + @userConfig[k] = ConfigHandler\getSerializableCopy v + changesMade = true + + return changesMade + + + --- Returns whether this view's hive overlaps with another view on the same handler. + -- @param otherView ConfigView + -- @return boolean|nil + -- @return string|nil err + isOverlappingView: (otherView) => + if @__configHandler != otherView.__configHandler + return nil, msgs.isOverlappingView.differentHandler\format otherView.__configHandler.filePath, + @__configHandler.filePath + + thisViewHivePathDepth, otherViewHivePathDepth = #@__hivePath, #otherView.__hivePath + + return true if thisViewHivePathDepth == 0 or otherViewHivePathDepth == 0 + + for i, key in ipairs @__hivePath + return false if key != otherView.__hivePath[i] + return true if i == thisViewHivePathDepth or i == otherViewHivePathDepth + + + --- Reloads only this view's hive from the config file. + -- @param[opt] waitLockTime number + -- @return boolean|nil + -- @return string|nil err + load: (waitLockTime) => + return false unless @__configHandler and @__configHandler.filePath + @__configHandler\load @, waitLockTime + + + --- Refreshes this view's userConfig from the handler's in-memory config. + -- @return boolean|nil + -- @return string|nil err + refresh: => + @userConfig, msg = @__configHandler\getHive @__hivePath + return if @userConfig + true + else nil, msg + + + --- Writes this view's hive to the config file. + -- @param[opt] waitLockTime number + -- @return boolean|nil + -- @return string|nil err + save: (waitLockTime) => + return false unless @__configHandler and @__configHandler.filePath + @__configHandler\save @, waitLockTime + + + -- deprecated, provided for compatibility with DepCtrl < 0.7 + write: (waitLockTime) => @save waitLockTime + + -- deprecated, provided for compatibility with DepCtrl < 0.7 + -- Attaches this view to a different config file path. + setFile: (filePath) => + ConfigHandler or= require "l0.DependencyControl.ConfigHandler" + logger = @__configHandler and @__configHandler.logger + handler, msg = ConfigHandler\get filePath, logger, true -- noLoad: caller loads separately + return nil, msg unless handler + @__configHandler = handler + @file = handler.filePath + return true + + -- deprecated, provided for compatibility with DepCtrl < 0.7 + -- Detaches this view from its config file (reverts to orphan/in-memory state). + unsetFile: => + ConfigHandler or= require "l0.DependencyControl.ConfigHandler" + @__configHandler = ConfigHandler nil, @__configHandler and @__configHandler.logger + @file = nil + @userConfig = {} + return true + + -- deprecated, provided for compatibility with DepCtrl < 0.7 + -- Returns a new ConfigView for a child hive of this view's handler. + getSectionHandler: (hivePath, defaults, noLoad) => + view, msg = @__configHandler\getView hivePath, defaults + return nil, msg unless view + view\load! unless noLoad + return view diff --git a/modules/DependencyControl/Crypto.moon b/modules/DependencyControl/Crypto.moon new file mode 100644 index 0000000..6afaced --- /dev/null +++ b/modules/DependencyControl/Crypto.moon @@ -0,0 +1,180 @@ +-- Cryptographic / hashing utilities. +-- Uses a fast native SHA-1 when one is available (CommonCrypto on macOS, libcrypto +-- on Linux, the Windows CryptoAPI), and falls back to a pure-Lua implementation +-- otherwise — so it always works, even headless / on platforms without the libs. +-- @class Crypto + +ffi = require "ffi" +bit = require "bit" +band, bor, bxor, bnot = bit.band, bit.bor, bit.bxor, bit.bnot +lshift, rol, tobit, tohex = bit.lshift, bit.rol, bit.tobit, bit.tohex + +msgs = { + sha1: { + badPayload: "Expected a string payload to hash, got a '%s'." + } +} + +-- Formats a 20-byte digest buffer as a 40-character lowercase hex string. +digestToHex = (buf) -> table.concat ["%02x"\format buf[i] for i = 0, 19] + +-- Pure-Lua SHA-1 (reference / fallback). Assumes a string input. +sha1Lua = (msg) -> + h0, h1, h2, h3, h4 = 0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0 + bytes = #msg + + -- append 0x80, pad with zeros until length ≡ 56 (mod 64) + msg ..= "\128" + while #msg % 64 != 56 + msg ..= "\0" + + -- append the original length in bits as a 64-bit big-endian integer + lenHi = math.floor bytes / 0x20000000 + lenLo = bytes * 8 % 0x100000000 + beBytes = (v) -> string.char( + band(math.floor(v / 0x1000000), 0xFF), band(math.floor(v / 0x10000), 0xFF), + band(math.floor(v / 0x100), 0xFF), band(v, 0xFF)) + msg ..= beBytes(lenHi) .. beBytes(lenLo) + + W = {} + for chunk = 1, #msg, 64 + for i = 0, 15 + b0, b1, b2, b3 = string.byte msg, chunk + i * 4, chunk + i * 4 + 3 + W[i] = bor lshift(b0, 24), lshift(b1, 16), lshift(b2, 8), b3 + for i = 16, 79 + W[i] = rol bxor(W[i - 3], W[i - 8], W[i - 14], W[i - 16]), 1 + + a, b, c, d, e = h0, h1, h2, h3, h4 + for i = 0, 79 + local f, k + if i < 20 + f, k = bor(band(b, c), band(bnot(b), d)), 0x5A827999 + elseif i < 40 + f, k = bxor(b, c, d), 0x6ED9EBA1 + elseif i < 60 + f, k = bor(band(b, c), bor(band(b, d), band(c, d))), 0x8F1BBCDC + else + f, k = bxor(b, c, d), 0xCA62C1D6 + temp = tobit rol(a, 5) + f + e + k + W[i] + e, d, c, b, a = d, c, rol(b, 30), a, temp + + h0 = tobit h0 + a + h1 = tobit h1 + b + h2 = tobit h2 + c + h3 = tobit h3 + d + h4 = tobit h4 + e + + tohex(h0) .. tohex(h1) .. tohex(h2) .. tohex(h3) .. tohex(h4) + +-- Attempts to set up a native SHA-1. Returns (fn, backendName) or nil. +-- Each fn takes a string and returns the 40-char hex digest. +setupNativeSha1 = -> + switch ffi.os + when "OSX" + -- CommonCrypto's CC_SHA1 is exported from libSystem (always loaded). + pcall ffi.cdef, "unsigned char* CC_SHA1(const void* data, uint32_t len, unsigned char* md);" + return unless pcall -> ffi.C.CC_SHA1 + digest = ffi.new "unsigned char[20]" + impl = (msg) -> + ffi.C.CC_SHA1 msg, #msg, digest + digestToHex digest + return impl, "CommonCrypto" + + when "Windows" + okLib, advapi = pcall ffi.load, "advapi32" + return unless okLib + pcall ffi.cdef, [[ + int CryptAcquireContextW(uintptr_t* phProv, const wchar_t* container, const wchar_t* provider, unsigned long provType, unsigned long flags); + int CryptCreateHash(uintptr_t hProv, unsigned int algId, uintptr_t hKey, unsigned long flags, uintptr_t* phHash); + int CryptHashData(uintptr_t hHash, const unsigned char* data, unsigned long len, unsigned long flags); + int CryptGetHashParam(uintptr_t hHash, unsigned long param, unsigned char* data, unsigned long* len, unsigned long flags); + int CryptDestroyHash(uintptr_t hHash); + ]] + PROV_RSA_FULL, CRYPT_VERIFYCONTEXT = 1, 0xF0000000 + CALG_SHA1, HP_HASHVAL = 0x8004, 2 + prov = ffi.new "uintptr_t[1]" + return if 0 == advapi.CryptAcquireContextW prov, nil, nil, PROV_RSA_FULL, CRYPT_VERIFYCONTEXT + hProv = prov[0] + digest = ffi.new "unsigned char[20]" + dlen = ffi.new "unsigned long[1]" + impl = (msg) -> + hashPtr = ffi.new "uintptr_t[1]" + return sha1Lua msg if 0 == advapi.CryptCreateHash hProv, CALG_SHA1, 0, 0, hashPtr + hHash = hashPtr[0] + advapi.CryptHashData hHash, msg, #msg, 0 + dlen[0] = 20 + advapi.CryptGetHashParam hHash, HP_HASHVAL, digest, dlen, 0 + advapi.CryptDestroyHash hHash + digestToHex digest + return impl, "CryptoAPI" + + else + -- Linux and other Unix: OpenSSL libcrypto. + local libcrypto + for name in *{"libcrypto.so.3", "libcrypto.so.1.1", "libcrypto.so", "crypto"} + okLib, lib = pcall ffi.load, name + if okLib + libcrypto = lib + break + return unless libcrypto + digest = ffi.new "unsigned char[20]" + + -- Preferred: the non-deprecated EVP interface (OpenSSL 1.1+/3.0). + pcall ffi.cdef, [[ + const void* EVP_sha1(void); + void* EVP_MD_CTX_new(void); + void EVP_MD_CTX_free(void* ctx); + int EVP_DigestInit_ex(void* ctx, const void* type, void* engine); + int EVP_DigestUpdate(void* ctx, const void* data, size_t count); + int EVP_DigestFinal_ex(void* ctx, unsigned char* md, unsigned int* size); + ]] + if pcall -> libcrypto.EVP_MD_CTX_new + md = libcrypto.EVP_sha1! + impl = (msg) -> + ctx = libcrypto.EVP_MD_CTX_new! + return sha1Lua msg if ctx == nil + libcrypto.EVP_DigestInit_ex ctx, md, nil + libcrypto.EVP_DigestUpdate ctx, msg, #msg + libcrypto.EVP_DigestFinal_ex ctx, digest, nil + libcrypto.EVP_MD_CTX_free ctx + digestToHex digest + return impl, "OpenSSL (EVP)" + + -- Fallback for very old libcrypto: the legacy one-shot (deprecated in 3.0 + -- but still exported; FFI resolves it at runtime regardless). + pcall ffi.cdef, "unsigned char* SHA1(const unsigned char* d, size_t n, unsigned char* md);" + return unless pcall -> libcrypto.SHA1 + impl = (msg) -> + libcrypto.SHA1 msg, #msg, digest + digestToHex digest + return impl, "OpenSSL (SHA1)" + +-- Resolve the SHA-1 backend, but only trust a native one if it reproduces the +-- reference digest (guards against a mis-bound symbol or wrong digest length). +sha1Impl, sha1Backend = sha1Lua, "lua" +ok, native, backendName = pcall setupNativeSha1 +if ok and native + verified, digest = pcall native, "abc" + if verified and digest == sha1Lua "abc" + sha1Impl, sha1Backend = native, backendName + +class Crypto + -- Name of the active SHA-1 backend ("CommonCrypto"/"OpenSSL"/"CryptoAPI"/"lua"). + @sha1Backend = sha1Backend + + --- Computes the SHA-1 digest of a string. + -- Accepts arbitrary binary data: Lua strings are byte-safe, so any byte sequence + -- (e.g. a file read in binary mode) hashes correctly. A raw FFI buffer must be + -- converted with ffi.string(buf, len) first. + -- Suitable for file integrity verification; not for security-sensitive use. + -- @param msg string the input bytes (may be binary) + -- @return string|nil a 40-character lowercase hex digest, or nil on invalid input + -- @return string|nil err + @sha1 = (msg) -> + return nil, msgs.sha1.badPayload\format type(msg) unless type(msg) == "string" + sha1Impl msg + + -- The pure-Lua reference implementation, exposed for tests / explicit fallback. + @_sha1Lua = sha1Lua + +return Crypto diff --git a/modules/DependencyControl/DownloadManager.moon b/modules/DependencyControl/DownloadManager.moon new file mode 100644 index 0000000..a3f6e87 --- /dev/null +++ b/modules/DependencyControl/DownloadManager.moon @@ -0,0 +1,96 @@ +-- DM.DownloadManager-compatible download manager. +-- Prefers the native DM.DownloadManager library (higher-performance, threaded). +-- Otherwise this class wraps DepCtrl's own Downloader engine to replicate the +-- native API, and registers itself under DM.DownloadManager so other scripts get +-- a working downloader too. +-- +-- DEPCTRL_PREFER_FFI_DOWNLOADER=1 skips the native library and forces the FFI path. + +unless os.getenv("DEPCTRL_PREFER_FFI_DOWNLOADER") == "1" + ok, native = pcall require, "DM.DownloadManager" + return native if ok + +Downloader = require "l0.DependencyControl.Downloader" +FileOps = require "l0.DependencyControl.FileOps" +Crypto = require "l0.DependencyControl.Crypto" + +msgs = { + checkMissingArgs: "Required arguments had the wrong type. Expected string, got '%s' and '%s'." + hashMismatch: "Hash mismatch. Got %s, expected %s." +} + +--- A download manager replicating the DM.DownloadManager API on top of the +-- DepCtrl Downloader engine. +-- @class DownloadManager +class DownloadManager + -- Matches the DM.DownloadManager dependency version declared in DependencyControl.moon + -- so DepCtrl accepts this implementation without a full managed record. + @version = "0.3.1" + + --- @param[opt] etagCacheDir string accepted for API compatibility; ETag caching is not implemented + new: (etagCacheDir) => + @downloader = Downloader! + -- the native API exposes .downloads directly; Downloader.clear empties it in + -- place, so this reference stays valid. .failedDownloads is rebuilt per run. + @downloads = @downloader.downloads + @failedDownloads = {} + + --- Queues a download, optionally verifying its SHA-1 once complete. + -- @param url string + -- @param outfile string full output path + -- @param[opt] sha1 string expected SHA-1 hash + -- @param[opt] etag string accepted for API compatibility; ignored + -- @return table|nil download + -- @return string|nil err + addDownload: (url, outfile, sha1, etag) => + @downloader\addDownload url, outfile, sha1 + + --- Performs all queued downloads (DM.DownloadManager-compatible). + -- @param[opt] callback function(progress) called with 0-100; returning a falsy + -- value cancels remaining downloads. Bridged to the engine's Progress event. + waitForFinish: (callback) => + if callback + -- bridge the DM-style cancel-capable callback onto the Progress event + onProgress = (_, percent) -> @downloader\cancel! unless callback percent + @downloader\on Downloader.Event.Progress, onProgress + @downloader\await! + @downloader\off Downloader.Event.Progress, onProgress + callback 100 unless @downloader.cancelled + else + @downloader\await! + -- rebuild the native-style failedDownloads list from each download's status + failed = Downloader.Download.Status.Failed + @failedDownloads = [dl for dl in *@downloads when dl.status == failed] + return + + --- @return number current aggregate progress (0-100) + progress: => @downloader\progress! + + cancel: => @downloader\cancel! + clear: => @downloader\clear! + + --- @return boolean whether an internet connection appears to be available + isInternetConnected: => @downloader\isInternetConnected! + + --- Computes the SHA-1 of a file's contents. + -- @return string|nil hexDigest + -- @return string|nil err + getFileSHA1: (filename) => FileOps.getHash filename, "sha1" + + --- Verifies a file against an expected SHA-1 hash. + -- @return boolean|nil match + -- @return string|nil err + checkFileSHA1: (filename, expected) => FileOps.verifyHash filename, expected, "sha1" + + --- Verifies a string against an expected SHA-1 hash. + -- @return boolean|nil match + -- @return string|nil err + checkStringSHA1: (str, expected) => + return nil, msgs.checkMissingArgs\format type(str), type(expected) unless type(expected) == "string" + actual, err = Crypto.sha1 str -- Crypto validates the payload type + return actual, err unless actual + return true if actual == expected\lower! + false, msgs.hashMismatch\format actual, expected + +package.loaded["DM.DownloadManager"] = DownloadManager +return DownloadManager diff --git a/modules/DependencyControl/Downloader.moon b/modules/DependencyControl/Downloader.moon new file mode 100644 index 0000000..a77d788 --- /dev/null +++ b/modules/DependencyControl/Downloader.moon @@ -0,0 +1,524 @@ +-- Non-blocking download manager with SHA-1 verification (pure FFI implementation). +-- This is DepCtrl's own downloader; the l0.DependencyControl.DownloadManager wrapper +-- decides whether to use this or the native DM.DownloadManager library. +-- +-- macOS/Linux: libcurl multi interface — parallel, scheduled by libcurl +-- Windows: WinINet driver multiplexed by our round-robin scheduler (parallel) +-- +-- The round-robin scheduler (`multiplex`) is decoupled from the transfer mechanism +-- via a driver interface {start, step, finish, shutdown}, so our scheduling and +-- orchestration logic can be unit-tested with a fake driver (no network). +-- +-- Downloads are queued with addDownload and run by await. Subscribe to progress +-- and completion via the Download / Downloader event APIs (on/off). ETag caching +-- is not implemented. + +ffi = require "ffi" +lfs = require "lfs" +Enum = require "l0.DependencyControl.Enum" +FileOps = require "l0.DependencyControl.FileOps" + +msgs = { + addMissingArgs: "Required arguments #1 (url) and #2 (outfile) had the wrong type. Expected string, got '%s' and '%s'." + failedToOpen: "Could not open file '%s'." + noBackend: "No download backend available." + httpStatus: "Server returned HTTP status %d." + readFailed: "Connection error while reading response." + openUrlFailed: "Could not open URL '%s'." + curlInit: "Failed to initialize curl." +} + +-- Lifecycle state of a single download. +DownloadStatus = Enum "DownloadStatus", { + Queued: "queued" -- created, not yet started + Active: "active" -- transfer in progress + Finished: "finished" -- completed successfully + Failed: "failed" -- completed with an error + Cancelled: "cancelled" -- cancelled before completion +} +-- statuses representing a download that is no longer in flight +isTerminalStatus = { + [DownloadStatus.Finished]: true + [DownloadStatus.Failed]: true + [DownloadStatus.Cancelled]: true +} + +-- Reports progress by emitting the downloader's Progress event, then returns +-- whether to keep going (a Progress listener may call cancel! to stop). +report = (manager, progress) -> + manager\_reportProgress progress + not manager.cancelled + +-- Backend-agnostic aggregate progress (0-100) from per-download state. +-- Relies on dl.bytesReceived / dl.totalBytes / dl.status, which every runner maintains. +computeProgress = (downloads) -> + total, now, allKnown, done = 0, 0, true, 0 + for dl in *downloads + if isTerminalStatus[dl.status] + done += 1 + total += dl.bytesReceived or 0 + now += dl.bytesReceived or 0 + else + if dl.totalBytes and dl.totalBytes > 0 + total += dl.totalBytes + now += dl.bytesReceived or 0 + else + allKnown = false + if total > 0 and allKnown + math.floor 100 * now / total + else + math.floor 100 * done / math.max #downloads, 1 + +-- Generic round-robin scheduler over a driver. This is the core scheduling logic +-- (the Windows production path, and the unit-tested path via a fake driver). +-- driver = { +-- start(dl) -> true | (false, errString) -- begin one transfer; set dl.totalBytes if known +-- step(dl) -> "more" | "done" | errString -- advance one chunk; update dl.bytesReceived +-- finish(dl) -> -- release one transfer's resources (idempotent) +-- shutdown() -> -- optional: release shared resources +-- } +multiplex = (manager, driver) -> + downloads = manager.downloads + active = {} + for dl in *downloads + dl.bytesReceived = 0 + ok, err = driver.start dl + if ok + dl.status = DownloadStatus.Active + active[#active + 1] = dl + else + dl\_complete err or "failed to start download" + + -- one pass per loop iteration steps every still-active transfer exactly once + while #active > 0 and not manager.cancelled + remaining = {} + for dl in *active + if dl._cancelRequested + driver.finish dl + dl\_cancel! + else + status = driver.step dl + if status == "more" + dl\_notifyProgress! + remaining[#remaining + 1] = dl + elseif status == "done" + driver.finish dl + dl\_complete! + else + driver.finish dl + dl\_complete status + active = remaining + break unless report manager, computeProgress downloads + + -- finalize any survivors as cancelled (whole-downloader cancellation) + for dl in *active + driver.finish dl + dl\_cancel! + driver.shutdown! if driver.shutdown + +-- Platform backend selection: sets defaultRunner(manager) and isInternetConnected(). +local defaultRunner, isInternetConnected + +if ffi.os != "Windows" + pcall ffi.cdef, "void* fopen(const char* path, const char* mode);" + pcall ffi.cdef, "int fclose(void* stream);" + pcall ffi.cdef, "int usleep(unsigned int usec);" + pcall ffi.cdef, [[ + void* curl_easy_init(void); + int curl_easy_setopt(void* handle, int option, ...); + void curl_easy_cleanup(void* handle); + int curl_easy_getinfo(void* handle, int info, ...); + const char* curl_easy_strerror(int errornum); + void* curl_multi_init(void); + int curl_multi_add_handle(void* multi, void* easy); + int curl_multi_remove_handle(void* multi, void* easy); + int curl_multi_perform(void* multi, int* running); + int curl_multi_wait(void* multi, void* extra_fds, unsigned int extra_nfds, int timeout_ms, int* numfds); + void curl_multi_cleanup(void* multi); + typedef struct CURLMsg { + int msg; + void* easy_handle; + union { void* whatever; int result; } data; + } CURLMsg; + CURLMsg* curl_multi_info_read(void* multi, int* msgs_in_queue); + ]] + + curlNames = ffi.os == "OSX" and {"libcurl.4.dylib", "libcurl.dylib", "curl"} or + {"libcurl.so.4", "libcurl.so", "curl"} + local curl + for name in *curlNames + loaded, lib = pcall ffi.load, name + if loaded + curl = lib + break + + if curl + CURLOPT_WRITEDATA = 10001 + CURLOPT_URL = 10002 + CURLOPT_USERAGENT = 10018 + CURLOPT_FOLLOWLOCATION = 52 + CURLOPT_FAILONERROR = 45 + CURLOPT_NOPROGRESS = 43 + CURLOPT_CONNECTTIMEOUT = 78 + CURLINFO_SIZE_DOWNLOAD = 0x300008 + CURLINFO_CONTENT_LENGTH_DOWNLOAD = 0x30000F + CURLMSG_DONE = 1 + + -- libcurl's varargs expect a C long for integer options; a bare Lua number + -- would be passed as a double, so cast explicitly. + setLong = (h, opt, v) -> curl.curl_easy_setopt h, opt, ffi.cast "long", v + -- cdata pointers can't be table keys reliably; key by address string instead. + key = (h) -> tostring ffi.cast "void *", h + + getDouble = (h, info) -> + out = ffi.new "double[1]" + curl.curl_easy_getinfo h, info, out + tonumber out[0] + + -- Unix uses curl's own multi scheduler rather than our round-robin loop. + defaultRunner = (manager) -> + downloads = manager.downloads + multi = curl.curl_multi_init! + handleMap = {} + + for dl in *downloads + dl.bytesReceived = 0 + file = ffi.C.fopen dl.outfile, "wb" + if file == nil + dl\_complete msgs.failedToOpen\format dl.outfile + continue + handle = curl.curl_easy_init! + if handle == nil + ffi.C.fclose file + dl\_complete msgs.curlInit + continue + curl.curl_easy_setopt handle, CURLOPT_URL, dl.url + curl.curl_easy_setopt handle, CURLOPT_USERAGENT, "DependencyControl" + curl.curl_easy_setopt handle, CURLOPT_WRITEDATA, file + setLong handle, CURLOPT_FOLLOWLOCATION, 1 + setLong handle, CURLOPT_FAILONERROR, 1 + setLong handle, CURLOPT_NOPROGRESS, 1 + setLong handle, CURLOPT_CONNECTTIMEOUT, 30 + dl._handle, dl._file = handle, file + dl.status = DownloadStatus.Active + handleMap[key handle] = dl + curl.curl_multi_add_handle multi, handle + + drain = -> + pending = ffi.new "int[1]" + while true + m = curl.curl_multi_info_read multi, pending + break if m == nil + continue unless m.msg == CURLMSG_DONE + dl = handleMap[key m.easy_handle] + continue unless dl + res = m.data.result + dl.bytesReceived = getDouble dl._handle, CURLINFO_SIZE_DOWNLOAD + ffi.C.fclose dl._file + curl.curl_multi_remove_handle multi, dl._handle + curl.curl_easy_cleanup dl._handle + dl._file, dl._handle = nil + transportError = res != 0 and ffi.string(curl.curl_easy_strerror res) or nil + dl\_complete transportError -- fires finish callbacks (e.g. hash verification) + + -- releases an easy handle + its output file (idempotent) + releaseHandle = (dl) -> + ffi.C.fclose dl._file if dl._file + curl.curl_multi_remove_handle multi, dl._handle + curl.curl_easy_cleanup dl._handle + dl._file, dl._handle = nil + + running = ffi.new "int[1]" + running[0] = 1 + numfds = ffi.new "int[1]" + while running[0] > 0 + curl.curl_multi_perform multi, running + drain! + for dl in *downloads + continue unless dl._handle + if dl._cancelRequested + releaseHandle dl + dl\_cancel! + else + dl.bytesReceived = getDouble dl._handle, CURLINFO_SIZE_DOWNLOAD + contentLen = getDouble dl._handle, CURLINFO_CONTENT_LENGTH_DOWNLOAD + dl.totalBytes = contentLen if contentLen > 0 + dl\_notifyProgress! + break unless report manager, computeProgress downloads + if running[0] > 0 + curl.curl_multi_wait multi, nil, 0, 100, numfds + ffi.C.usleep 10000 if numfds[0] == 0 + drain! + + -- finalize any survivors as cancelled (whole-downloader cancellation) + for dl in *downloads + if dl._handle + releaseHandle dl + dl\_cancel! + curl.curl_multi_cleanup multi + + else + defaultRunner = (manager) -> + dl\_complete msgs.noBackend for dl in *manager.downloads + + isInternetConnected = -> true -- best-effort: assume connected, let downloads report real errors + +else + pcall ffi.cdef, "int MultiByteToWideChar(unsigned int cp, unsigned long flags, const char* str, int cbMulti, wchar_t* wide, int cchWide);" + pcall ffi.cdef, [[ + void* InternetOpenW(const wchar_t* agent, unsigned long accessType, const wchar_t* proxy, const wchar_t* proxyBypass, unsigned long flags); + void* InternetOpenUrlW(void* session, const wchar_t* url, const wchar_t* headers, unsigned long headersLen, unsigned long flags, uintptr_t context); + int InternetReadFile(void* hFile, void* buffer, unsigned long toRead, unsigned long* read); + int InternetCloseHandle(void* h); + int HttpQueryInfoW(void* hRequest, unsigned long infoLevel, void* buffer, unsigned long* bufferLen, unsigned long* index); + int InternetGetConnectedState(unsigned long* flags, unsigned long reserved); + ]] + + haveKernel32, kernel32 = pcall ffi.load, "kernel32" + haveWinInet, winInet = pcall ffi.load, "winInet" + + CP_UTF8 = 65001 + toWide = (s) -> + n = kernel32.MultiByteToWideChar CP_UTF8, 0, s, -1, nil, 0 + buf = ffi.new "wchar_t[?]", n + kernel32.MultiByteToWideChar CP_UTF8, 0, s, -1, buf, n + buf + + INTERNET_FLAG_RELOAD = 0x80000000 + INTERNET_FLAG_NO_CACHE_WRITE = 0x04000000 + HTTP_QUERY_STATUS_CODE = 19 + HTTP_QUERY_CONTENT_LENGTH = 5 + HTTP_QUERY_FLAG_NUMBER = 0x20000000 + CHUNK = 16384 + + queryNumber = (request, info) -> + out = ffi.new "unsigned long[1]" + len = ffi.new "unsigned long[1]" + len[0] = 4 + ok = winInet.HttpQueryInfoW request, bit.bor(info, HTTP_QUERY_FLAG_NUMBER), out, len, nil + ok != 0 and tonumber(out[0]) or nil + + if haveKernel32 and haveWinInet + -- A WinINet driver for `multiplex`: one request + output file per download, + -- advanced one chunk per step. The scheduler round-robins across them. + makeWinINetDriver = -> + session = winInet.InternetOpenW toWide("DependencyControl"), 0, nil, nil, 0 + buffer = ffi.new "char[?]", CHUNK + read = ffi.new "unsigned long[1]" + { + start: (dl) -> + out, err = io.open dl.outfile, "wb" + return false, (err or msgs.failedToOpen\format dl.outfile) unless out + request = winInet.InternetOpenUrlW session, toWide(dl.url), nil, 0, + bit.bor(INTERNET_FLAG_RELOAD, INTERNET_FLAG_NO_CACHE_WRITE), 0 + if request == nil + out\close! + return false, msgs.openUrlFailed\format dl.url + status = queryNumber request, HTTP_QUERY_STATUS_CODE + if status and status >= 400 + winInet.InternetCloseHandle request + out\close! + return false, msgs.httpStatus\format status + dl._request, dl._out = request, out + dl.totalBytes = queryNumber request, HTTP_QUERY_CONTENT_LENGTH + true + + step: (dl) -> + return msgs.readFailed if 0 == winInet.InternetReadFile dl._request, buffer, CHUNK, read + n = tonumber read[0] + return "done" if n == 0 + dl._out\write ffi.string buffer, n + dl.bytesReceived += n + "more" + + finish: (dl) -> + winInet.InternetCloseHandle dl._request if dl._request + dl._out\close! if dl._out + dl._request, dl._out = nil + + shutdown: -> + winInet.InternetCloseHandle session + } + + defaultRunner = (manager) -> + multiplex manager, makeWinINetDriver! + + else + defaultRunner = (manager) -> + dl\_complete msgs.noBackend for dl in *manager.downloads + + isInternetConnected = -> + return true unless haveWinInet + flags = ffi.new "unsigned long[1]" + winInet.InternetGetConnectedState(flags, 0) != 0 + + +--- Minimal event registration mixin: on(event, cb) / off(event, cb) / _emit(event, ...). +-- Subclasses provide an `@Event` Enum that defines the valid event values. +-- @class EventEmitter +class EventEmitter + new: => + @_listeners = {} + + --- Registers a callback for an event. + -- @param event the event value (a member of the subclass's @Event enum) + -- @param callback function called with the emitter instance (plus any event args) + -- @return self (for chaining) + on: (event, callback) => + valid, err = @@Event\validate event, "event" + error err unless valid + listeners = @_listeners[event] + unless listeners + listeners = {} + @_listeners[event] = listeners + listeners[#listeners + 1] = callback + return @ + + --- Unregisters a previously-registered callback for an event. + -- @param event the event value + -- @param callback the exact callback passed to on + -- @return self (for chaining) + off: (event, callback) => + listeners = @_listeners[event] + return @ unless listeners + for i = #listeners, 1, -1 + table.remove listeners, i if listeners[i] == callback + return @ + + -- Invokes all listeners for an event with (self, ...). Iterates a snapshot so + -- a listener may safely on/off during dispatch. + _emit: (event, ...) => + listeners = @_listeners[event] + return unless listeners + cb @, ... for cb in *[l for l in *listeners] + + +--- A single download: its URL, output path, transfer state, and event callbacks. +-- Events (see Download.Event): Progress (data arrived), Finish (reached a terminal +-- status). A Finish listener may downgrade the status via markFailed (e.g. for a +-- failed hash verification). The current state is exposed via @status (Download.Status). +-- @class Download +class Download extends EventEmitter + @Status = DownloadStatus + @Event = Enum "DownloadEvent", { Progress: "progress", Finish: "finish" } + + --- @param url string + -- @param outfile string full output path + -- @param[opt] id number an identifier assigned by the Downloader + new: (@url, @outfile, @id) => + super! + @bytesReceived = 0 + @totalBytes = nil + @status = DownloadStatus.Queued + @error = nil + + --- Requests cancellation of this download. The downloader releases its + -- resources and sets the status to Cancelled on its next scheduling pass. + cancel: => @_cancelRequested = true + + --- Marks the download as failed (e.g. from a Finish listener performing + -- hash verification). + -- @param err string the failure reason + markFailed: (err) => + @error = err + @status = @@Status.Failed + + -- Runner-internal: fire Progress listeners. + _notifyProgress: => @_emit @@Event.Progress + + -- Runner-internal: finalize the transfer (success or transport error) and fire + -- Finish listeners (which may downgrade the status via markFailed). + -- @param[opt] transportError string a transport-level error, if any + _complete: (transportError) => + return if @_finalized + @_finalized = true + if transportError + @error = transportError + @status = @@Status.Failed + else + @status = @@Status.Finished + @_emit @@Event.Finish + + -- Runner-internal: finalize as cancelled and fire Finish listeners. + _cancel: => + return if @_finalized + @_finalized = true + @status = @@Status.Cancelled + @_emit @@Event.Finish + + +--- Manages a set of concurrent downloads. This is DepCtrl's own engine; the +-- DM.DownloadManager-compatible API lives in l0.DependencyControl.DownloadManager. +-- Events (see Downloader.Event): Progress (overall %), Finished (await completed). +-- @class Downloader +class Downloader extends EventEmitter + @Download = Download + @Event = Enum "DownloaderEvent", { Progress: "progress", Finished: "finished" } + -- Exposed so tests (and custom runners) can drive the round-robin scheduler + -- with an injected driver. + @multiplex = multiplex + + --- Creates a downloader. + -- @param[opt] runner function(downloader, callback) overrides the transfer implementation + new: (runner) => + super! + @downloads = {} + @cancelled = false + @_runner = runner or defaultRunner + + --- Queues a download. Transfers happen later, in await. + -- Register progress/finish listeners on the returned Download as needed. + -- @param url string + -- @param outfile string full output path (relative paths unsupported) + -- @param[opt] sha1 string expected SHA-1 hash; verified automatically on finish + -- @return Download|nil download + -- @return string|nil err + addDownload: (url, outfile, sha1) => + unless type(url) == "string" and type(outfile) == "string" + return nil, msgs.addMissingArgs\format type(url), type(outfile) + + dir = outfile\match "^(.*[/\\])" + lfs.mkdir dir if dir and lfs.attributes(dir, "mode") != "directory" + + @_lastId = (@_lastId or 0) + 1 + download = Download url, outfile, @_lastId + + if type(sha1) == "string" + expected = sha1\lower! + -- piggyback on the finish event to verify the downloaded file's hash + download\on Download.Event.Finish, (dl) -> + return unless dl.status == Download.Status.Finished -- only verify successful transfers + ok, msg = FileOps.verifyHash dl.outfile, expected, FileOps.HashType.SHA1 + dl\markFailed msg unless ok + + @downloads[#@downloads + 1] = download + download + + --- Performs all queued downloads, blocking until they finish or are cancelled. + -- Subscribe to Progress/Finished via on; a Progress listener may call cancel!. + -- Inspect each download's final state via its @status (Download.Status). + -- @return Downloader self (for chaining) + await: => + @_runner @ + @_emit @@Event.Finished + return @ + + --- @return number current aggregate progress (0-100) + progress: => computeProgress @downloads + + -- Runner-internal: emit the Progress event with the current overall percentage. + _reportProgress: (percent) => @_emit @@Event.Progress, percent + + --- Cancels all remaining downloads (e.g. from within a Progress listener). + cancel: => @cancelled = true + + --- Removes all downloads and resets state. + -- Empties the array in place so external references stay valid. + clear: => + @downloads[i] = nil for i = #@downloads, 1, -1 + @cancelled = false + + --- @return boolean whether an internet connection appears to be available + isInternetConnected: => isInternetConnected! + +return Downloader diff --git a/modules/DependencyControl/Enum.moon b/modules/DependencyControl/Enum.moon new file mode 100644 index 0000000..8f8da85 --- /dev/null +++ b/modules/DependencyControl/Enum.moon @@ -0,0 +1,127 @@ +Logger = require "l0.DependencyControl.Logger" + +reservedKeys = { + "describe", + "elements" + "keys", + "name", + "test", + "values" +} + +reservedKeySet = {v, true for v in *reservedKeys} + +msgs = { + __index: { + invalidKeyAccess: "Cannot access invalid key '%s' on Enum '%s'" + } + __newindex: { + immutableError: "Cannot assign field '%s' to '%s' on immutable Enum '%s'." + } + new: { + valueAlreadyTaken: "Could not define '%s' in enum '%s': value %s is already taken by '%s'." + keyAlreadyDefined: "Cannot redefine key '%s' in enum '%s'." + noReservedKeys: "Key may not be any of the reserved words [#{table.concat reservedKeys, ', '}] or start with '__' (was '%s')." + missingOrInvalidName: "Missing or invalid Enum name (expected a string, got a '%s')." + } + describe: { + valueNotDefined: "Value '%s' is not defined in enum '%s'." + } + validate: { + argPrefix: "Argument %s: " + invalidValue: "%sInvalid value '%s' for enum '%s'." + } +} + +--- An immutable enumeration type with value/key reverse lookup. +-- @class Enum +class Enum + @logger = Logger fileBaseName: "DependencyControl.Enum" + @reservedKeys = reservedKeys + @isReservedKey = (k) => + return type(k) == "string" and (k\sub(1,2) == "__" or reservedKeySet[k]) or false + + + --- Creates an enum from a table of key/value pairs or a list of names. + -- @param name string + -- @param values table + -- @param[opt] logger Logger + new: (@name, values, @__logger = @@logger) => + @__logger\assert type(@name) == "string", msgs.new.missingOrInvalidName, Logger\describeType @name + @elements, @__valuesToKeys, @values, @keys = {}, {}, {}, {} + + for k, v in pairs values + -- we support lists as input, but we do not support numerical keys, which is sane + if "number" == type k + k, v = v, k + + @__logger\assert not @@isReservedKey(k), msgs.new.noReservedKeys, k + @__logger\assert @elements[k] == nil, msgs.new.keyAlreadyDefined, k, @name + @__logger\assert @__valuesToKeys[v] == nil, msgs.new.valueAlreadyTaken, k, @name, v, @__valuesToKeys[v] + + @elements[k], @__valuesToKeys[v] = v, k + table.insert @values, v + table.insert @keys, k + + meta = getmetatable @ + clsIdx = meta.__index + + setmetatable @, setmetatable { + __index: (k) => + if @elements[k] != nil + return @elements[k] + + v = switch type clsIdx + when "function" then clsIdx @, k + when "table" then clsIdx[k] + return v if v != nil + + @__logger\error msgs.__index.invalidKeyAccess, k, @name + + __newindex: (k, v) => + @__logger\error msgs.__newindex.immutableError, k, v, @name + }, clsIdx + + + --- Returns whether the given key is defined in this enum. + -- @param key string + -- @return boolean + -- @return any|nil value + test: (key) => + val = @elements[key] + return val != nil and true or false, val + + + --- Returns the key name(s) for one or more values. + -- @param values any + -- @param[opt] join string|boolean + -- @return string|string[]|nil + -- @return string|nil err + describe: (values, join = false) => + key = @__valuesToKeys[values] + if key != nil + return key + + if "table" != type values + return nil, msgs.describe.valueNotDefined\format values, @name + + keys = for v in *values + key = @__valuesToKeys[v] + if key == nil + join and '' or nil + else key + + return join and table.concat(keys, join == true and ', ' or join) or keys + + + --- Validates that a value is a member of this enum. + -- @param value any + -- @param[opt] argName string + -- @return boolean|nil + -- @return string|nil err + validate: (value, argName) => + if value == nil or @__valuesToKeys[value] == nil + prefix = argName != nil and msgs.validate.argPrefix\format(argName) or "" + return nil, msgs.validate.invalidValue\format prefix, value, @name + + return true diff --git a/modules/DependencyControl/FileOps.moon b/modules/DependencyControl/FileOps.moon index 1369a60..e4f6aa0 100644 --- a/modules/DependencyControl/FileOps.moon +++ b/modules/DependencyControl/FileOps.moon @@ -1,10 +1,13 @@ ffi = require "ffi" -re = require "aegisub.re" lfs = require "lfs" - Logger = require "l0.DependencyControl.Logger" -local ConfigHandler +Common = require "l0.DependencyControl.Common" +Crypto = require "l0.DependencyControl.Crypto" +Enum = require "l0.DependencyControl.Enum" +local ConfigView +--- Filesystem utility helpers used by DependencyControl. +-- @class FileOps class FileOps msgs = { generic: { @@ -16,6 +19,12 @@ class FileOps noAttribute: "Can't find attriubte with name '%s'." } + createConfig: { + handlerFailed: "Couldn't create ConfigHandler for the FileOps configuration file: %s" + }, + createTempDir: { + failedCreate: "Failed to create temporary directory: %s" + } mkdir: { createError: "Error creating directory: %s." otherExists: "Couldn't create directory because a %s of the same name is already present." @@ -40,12 +49,31 @@ class FileOps couldntRemoveFiles: "Move operation suceeded to copied the file(s) to the target location, but some of the source files couldn't be removed:\n%s\n%s" cantCopy: "Move operation failed to copy '%s' to '%s' (%s) after a failed rename attempt (%s)." } + readFile: { + cantOpen: "Couldn't open file '%s' for reading: %s" + cantRead: "An error occurred while trying to read from file '%s': %s" + notAFile: "Can only read files but supplied path '%s' points to a %s." + } + verifyHash: { + badHash: "Argument #2 (hash) must be a string, got '%s'." + mismatch: "Hash mismatch. Got %s, expected %s." + } + remove: { + noConfigReschedule: "Couldn't load the FileOps config file (%s) - deletions of %s cannot be rescheduled!" + } rmdir: { emptyPath: "Argument #1 (path) must not be an empty string." couldntRemoveFiles: "Some of the files and folders in the specified directory couldn't be removed:\n%s" couldntRemoveDir: "Error removing empty directory: %s." } + runScheduledRemoval: { + noConfigReschedule: "Couldn't load the FileOps config file (%s) - rescheduled deletions will not be performed!" + } + getNamespacedPath: { + badBasePath: "Provided base path '%s' is not a valid full path (%s)." + badPath: "Generated namespaced path '%s' is not a valid full path (%s)." + } validateFullPath: { badType: "Argument #1 (path) had the wrong type. Expected 'string', got '%s'." tooLong: "The specified path exceeded the maximum length limit (%d > %d)." @@ -57,26 +85,55 @@ class FileOps } } - devPattern = ffi.os == "Windows" and "[A-Za-z]:" or "/[^\\\\/]+" + windowsReservedNameSet = {n, true for n in *{ + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + }} pathMatch = { sep: ffi.os == "Windows" and "\\" or "/" - pattern: re.compile "^(#{devPattern})((?:[\\\\/][^\\\\/]*[^\\\\/\\s\\.])*)[\\\\/]([^\\\\/]*[^\\\\/\\s\\.])?$" invalidChars: '[<>:"|%?%*%z%c;]' - reservedNames: re.compile "[\\\\/](CON|COM[1-9]|PRN|AUX|NUL|LPT[1-9])(?:[\\\\/].*?)?$", re.ICASE maxLen: 255 } + -- supported file hash algorithms, keyed by HashType value + HashType = Enum "FileOpsHashType", { SHA1: "sha1" } + @HashType = HashType + hashAlgorithms = { [HashType.SHA1]: Crypto.sha1 } @logger = Logger! + @pathSep = pathMatch.sep createConfig = (noLoad, configDir) -> FileOps.configDir = configDir if configDir - ConfigHandler or= require "l0.DependencyControl.ConfigHandler" - FileOps.config or= ConfigHandler "#{FileOps.configDir}/l0.#{FileOps.__name}.json", - {toRemove: {}}, nil, noLoad, FileOps.logger + ConfigView or= require "#{Common.moduleName}.ConfigView" + unless FileOps.config + FileOps.config = ConfigView\get "#{FileOps.configDir}/l0.#{FileOps.__name}.json", + nil, {toRemove: {}}, FileOps.logger, noLoad + return nil, msgs.createConfig.handlerFailed\format "constructor returned nil" unless FileOps.config return FileOps.config + --- Creates a unique temporary directory and returns its path. + -- @return string? tempDirPath absolute path to the created temporary directory or nil if the directory couldn't be created + -- @return string? err error message if the directory couldn't be created + createTempDir: () -> + tempDir = FileOps.getTempDir() + res, dir = FileOps.mkdir tempDir + return tempDir if res + return nil, msgs.createTempDir.failedCreate\format err + + --- Generates a unique temporary file path. + -- @return string tempFilePath absolute path to a unique temporary directory that does not exist yet + getTempDir: () -> + return aegisub.decode_path "?temp/#{Common.moduleName}_#{'%04X'\format math.random 0, 16^4-1}" + + --- Removes one or more files/directories and optionally reschedules failed removals. + -- @param paths string|(string|string)[] path or paths to the files/directories to remove. If an array of paths is provided, each path can be specified as a string or an array of path segments. + -- @param[opt] recurse boolean + -- @param[opt] reSchedule boolean + -- @return boolean|nil overallSuccess + -- @return table details + -- @return string|nil firstErr remove: (paths, recurse, reSchedule) -> - config = createConfig true - configLoaded, overallSuccess, details, firstErr = false, true, {} + config, configLoaded, overallSuccess, details, firstErr = nil, false, true, {} paths = {paths} unless type(paths) == "table" for path in *paths @@ -93,8 +150,16 @@ class FileOps -- load the FileOps configuration file and reschedule deletions unless configLoaded - FileOps.config\load! - configLoaded = true + config, msg = createConfig true + if config + FileOps.config\load! + configLoaded = true + else + FileOps.logger\warn msgs.remove.noConfigReschedule, msg, FileOps.logger\dumpToString paths + details[path] = {nil, err} + overallSuccess = nil + continue + config.c.toRemove[path] = os.time! -- mark the operations as failed "for now", indicating a second attempt has been scheduled details[path] = {false, err} @@ -108,8 +173,16 @@ class FileOps config\write! if configLoaded return overallSuccess, details, firstErr + --- Replays removals previously scheduled by @{FileOps:remove}. + -- @param[opt] configDir string + -- @return boolean + -- @return string|nil err runScheduledRemoval: (configDir) -> - config = createConfig false, configDir + config, msg = createConfig false, configDir + unless config + msg = msgs.runScheduledRemoval.noConfigReschedule\format msg + FileOps.logger\warn msg + return nil, msg paths = [path for path, _ in pairs config.c.toRemove] if #paths > 0 -- rescheduled removals will not be rescheduled another time @@ -118,6 +191,11 @@ class FileOps config\write! return true + --- Copies a file to a target path. + -- @param source string + -- @param target string + -- @return boolean success + -- @return string|nil err copy: ( source, target ) -> -- source check mode, sourceFullPath, _, _, fileName = FileOps.attributes source, "mode" @@ -163,7 +241,27 @@ class FileOps else return false, msgs.copy.genericError\format sourceFullPath, targetFullPath, msg - + --- Joins multiple path segments into a single path string. + -- @param ... string|string[] one or more path segments, or arrays of path segments + -- @return string joinedPath the path segments joined by os-specific path separators + joinPath: (...) -> + flatPathSegments = [x for v in *{...} for x in *(type(v) == "table" and v or {v})] + + return table.concat flatPathSegments, FileOps.pathSep + + --- Returns an iterator over the non-empty components of a path, split on any separator. + -- Equivalent to collecting `path:gmatch("[^/\\]+")`. + -- To get an array instead: `[seg for seg in FileOps.pathSegments(path)]` + -- @tparam string path + -- @return iterator + pathSegments: (path) -> path\gmatch "[^/\\]+" + + --- Moves a file to a target path, optionally replacing existing targets. + -- @param source string + -- @param target string + -- @param[opt] overwrite boolean + -- @return boolean success + -- @return string|nil err move: (source, target, overwrite) -> mode, err = FileOps.attributes target, "mode" if mode == "file" @@ -216,6 +314,50 @@ class FileOps return true + --- Reads and returns the full contents of a file. + -- @param path string|string[] path or path segments to the file to read + -- @return string? data the contents of the file, or nil if an error occurred + -- @return string? err an error message if an error occurred, or nil if the file was read successfully + readFile: (path) -> + mode, fullPath = FileOps.attributes path, "mode" + return nil, msgs.readFile.cantOpen\format path, fullPath unless mode + return nil, msgs.readFile.notAFile\format path, mode if mode != "file" + + handle, msg = io.open fullPath, "rb" + return nil, msgs.readFile.cantOpen\format fullPath, msg unless handle + + data, msg = handle\read "*a" + handle\close! + + if data + return data + else return nil, msgs.readFile.cantRead\format path, msg + + --- Computes the hash of a file's contents. + -- @param fileName string|string[] path or path segments to the file to hash + -- @param[opt=HashType.SHA1] hashType FileOps.HashType the hash algorithm to use + -- @return string? hexDigest the lowercase hex digest, or nil if an error occurred + -- @return string? err an error message if an error occurred + getHash: (fileName, hashType = HashType.SHA1) -> + valid, err = HashType\validate hashType, "hashType" + return nil, err unless valid + data, readErr = FileOps.readFile fileName + return nil, readErr unless data + return hashAlgorithms[hashType] data + + --- Verifies that a file's contents match an expected hash. + -- @param fileName string|string[] path or path segments to the file to verify + -- @param hash string the expected hex digest (case-insensitive) + -- @param[opt=HashType.SHA1] hashType FileOps.HashType the hash algorithm to use + -- @return boolean? match true on match, false on mismatch, or nil on error + -- @return string? err the mismatch detail or error message + verifyHash: (fileName, hash, hashType = HashType.SHA1) -> + return nil, msgs.verifyHash.badHash\format type hash unless type(hash) == "string" + actual, err = FileOps.getHash fileName, hashType + return actual, err unless actual + return true if actual == hash\lower! + return false, msgs.verifyHash.mismatch\format actual, hash + rmdir: (path, recurse = true) -> return nil, msgs.rmdir.emptyPath if path == "" mode, path = FileOps.attributes path, "mode" @@ -236,6 +378,11 @@ class FileOps return true + --- Creates a directory. + -- @param path string|string[] path or path segments to the directory to create + -- @param isFile boolean whether the path is a file path (causes the last segment to be discarded when checking/creating the directory) + -- @return boolean true if the directory was created, false if it already existed, or nil if an error occurred + -- @return string dirPathOrError the path to the existing or created directory, or an error message if an error occurred mkdir: (path, isFile) -> mode, fullPath, dev, dir, file = FileOps.attributes path, "mode" dir = isFile and table.concat({dev,dir or file}) or fullPath @@ -253,6 +400,14 @@ class FileOps return nil, msgs.mkdir.otherExists\format mode return false, dir + --- Retrieves file or directory attributes. + -- @param path string|string[] Either a path or an array of path segments + -- @param key string|nil attribute name to retrieve (e.g. "mode", "size", "modification"), or nil to retrieve the full attribute table + -- @return table|string|number|boolean|nil attr the requested attribute(s), or nil if an error occurred + -- @return string fullPath the validated full path to the file or directory, or an error message if the path was invalid + -- @return string? device the device component of the path, or nil if the path was invalid + -- @return string? dir the directory component of the path, or nil if the path was invalid + -- @return string? file the file name component of the path, or nil if the path was invalid or pointed to attributes: (path, key) -> fullPath, dev, dir, file = FileOps.validateFullPath path unless fullPath @@ -269,8 +424,18 @@ class FileOps return attr, fullPath, dev, dir, file + --- Validates and normalizes an absolute filesystem path. + -- @param path string|string[] Either a path or an array of path segments + -- @param[opt] checkFileExt boolean + -- @return string|nil normalizedPath + -- @return string|nil err + -- @return string|nil device + -- @return string|nil dir + -- @return string|nil file validateFullPath: (path, checkFileExt) -> - if type(path) != "string" + if type(path) == "table" + path = FileOps.joinPath path + elseif type(path) != "string" return nil, msgs.validateFullPath.badType\format type(path) -- expand aegisub path specifiers path = aegisub.decode_path path @@ -286,22 +451,50 @@ class FileOps invChar = path\match pathMatch.invalidChars, ffi.os == "Windows" and 3 or nil if invChar return false, msgs.validateFullPath.invalidChars\format invChar - -- check for reserved file names - reserved = pathMatch.reservedNames\match path - if reserved - return false, msgs.validateFullPath.reservedNames\format reserved[2].str -- check for path escalation if path\match "%.%." return false, msgs.validateFullPath.parentPath - -- check if we got a valid full path - matches = pathMatch.pattern\match path - dev, dir, file = matches[2].str, matches[3].str, matches[4].str if matches + -- parse path structure + dev = if ffi.os == "Windows" then path\match "^[A-Za-z]:" else path\match "^/[^/\\]+" unless dev return false, msgs.validateFullPath.notFullPath + rest = path\sub #dev + 1 + dir, file = rest\match "^(.*)[/\\]([^/\\]*)$" + unless dir + return false, msgs.validateFullPath.notFullPath + for segment in FileOps.pathSegments rest + if ffi.os == "Windows" + segmentWithoutExt = segment\match("^[^%.]+") or segment + if windowsReservedNameSet[segmentWithoutExt\upper!] + return false, msgs.validateFullPath.reservedNames\format segmentWithoutExt + unless segment\match "[^%.%s]$" + return false, msgs.validateFullPath.notFullPath + file = file != "" and file or nil if checkFileExt and not (file and file\match ".+%.+") return false, msgs.validateFullPath.missingExt - path = table.concat({dev, dir, file and pathMatch.sep, file}) - - return path, dev, dir, file \ No newline at end of file + path = table.concat {dev, dir, file and pathMatch.sep, file} + return path, dev, dir, file + + --- Converts a base path and namespace into a namespaced filesystem path. + -- Dots in the namespace are converted to path separators when nested is true. + -- @param basePath string|string[] base path or path segments to the directory under which the namespaced path should be created + -- @param namespace string + -- @param ext string file extension (including dot) + -- @param[opt=true] nested boolean + -- @return string|nil path + -- @return string|nil err + getNamespacedPath: (basePath, namespace, ext, nested = true) -> + res, msg = Common.validateNamespace namespace + return nil, msg unless res + + fullBasePath, msg = FileOps.validateFullPath basePath + return nil, msgs.getNamespacedPath.badBasePath\format basePath, msg unless fullBasePath + + namespacePath = nested and namespace\gsub("%.", FileOps.pathSep) or namespace + fullPath = FileOps.joinPath fullBasePath, "#{namespacePath}#{ext}" + normalizedFullPath, msg = FileOps.validateFullPath fullPath + return nil, msgs.getNamespacedPath.badPath\format fullPath, msg unless normalizedFullPath + + return normalizedFullPath diff --git a/modules/DependencyControl/Lock.moon b/modules/DependencyControl/Lock.moon new file mode 100644 index 0000000..94da0bb --- /dev/null +++ b/modules/DependencyControl/Lock.moon @@ -0,0 +1,139 @@ +mutex = require "l0.DependencyControl.TerribleMutex" +Timer = require "l0.DependencyControl.Timer" +Logger = require "l0.DependencyControl.Logger" +Enum = require "l0.DependencyControl.Enum" + +DEFAULT_LOCK_WAIT_INTERVAL = 250 +DEFAULT_EXPIRY_DURATION = 5 * 60 +DEFAULT_HOLDER_NAME = "unknown" + +--- Cooperative mutex-based lock with a sqlite-compatible interface. +-- The namespace and resource parameters are accepted for interface compatibility +-- with the sqlite Lock but are not used for actual locking — the underlying +-- BadMutex is a single global mutex, so only one lock can be held at a time +-- regardless of namespace/resource. This is sufficient since no scripts write +-- to multiple config files concurrently. +-- @class Lock +class Lock + msgs = { + new: { + lockNotReleased: "Lock holder '%s' (%s) did not release its lock on resource '%s.%s' before discarding it, cleaning up..." + } + lock: { + trying: "Trying to get a lock on resource '%s.%s' for holder '%s' (%s). Timeout in %ims..." + failed: "Could not attain lock on resource '%s.%s' for holder '%s' (%s): %s" + heldByOther: "Lock on resource '%s.%s' is currently held, retrying in %ims..." + alreadyHeld: "'%s' (%s) is already holding the lock on resource '%s.%s'." + attained: "'%s' (%s) attained the lock on resource '%s.%s'." + timeout: "Gave up trying to attain a lock on resource '%s.%s' for holder '%s' (%s) after timeout was reached." + } + release: { + failed: "Could not release lock on resource '%s.%s' for '%s' (%s): %s" + notHeld: "lock is not currently held by this instance" + released: "'%s' (%s) released its lock on resource '%s.%s'." + } + } + + @logger = Logger fileBaseName: "DependencyControl.Lock" + + @LockState = Enum "LockState", { + Unknown: -1 + Unavailable: 0 + Available: 1 + Held: 2 + }, @logger + LockState or= @LockState + + @uuid = -> + -- https://gist.github.com/jrus/3197011 + "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"\gsub "[xy]", (c) -> + v = c == "x" and math.random(0, 0xf) or math.random 8, 0xb + return "%x"\format v + + --- Creates a lock for the given resource. + -- @param args table + new: (args) => + {namespace: @namespace, resource: @resource, holderName: @holderName, logger: @logger, expiresAfter: @expiresAfter} = args + @logger or= @@logger + @expiresAfter or= DEFAULT_EXPIRY_DURATION + @holderName or= DEFAULT_HOLDER_NAME + @instanceId = @@uuid! + + -- mutable held-state shared with the GC canary (avoids capturing self) + held = {false} + @_held = held + + -- release any still-held lock when this object is garbage collected + -- the canary must not hold a reference to self, or it will never be collected + holderName, instanceId, namespace, resource, logger = @holderName, @instanceId, @namespace, @resource, @logger + canary = newproxy true + (getmetatable canary).__gc = -> + if held[1] + pcall logger.warn, logger, msgs.new.lockNotReleased, holderName, instanceId, namespace, resource + pcall -> + mutex.unlock! + held[1] = false + + meta = getmetatable @ + setmetatable @, { + __metatable: meta + __index: meta.__index + __canary: canary + } + + --- Returns the current lock state for this instance. + -- Returns Held if this instance holds the lock, Unknown otherwise + -- (the global mutex cannot be queried without attempting to acquire it). + -- @return number LockState + getState: => + return if @_held[1] + @@LockState.Held + else + @@LockState.Unknown + + --- Attempts to acquire the lock, waiting up to timeout milliseconds. + -- @param[opt=math.huge] timeout number + -- @param[opt=250] lockWaitInterval number + -- @return number LockState + -- @return number timePassed + lock: (timeout = math.huge, lockWaitInterval = DEFAULT_LOCK_WAIT_INTERVAL) => + timePassed = 0 + while timeout == math.huge or timeout >= timePassed + @logger\trace msgs.lock.trying, @namespace, @resource, @holderName, @instanceId, + timeout == math.huge and math.huge or timeout - timePassed + + state = @getState! + switch state + when @@LockState.Held + @logger\trace msgs.lock.alreadyHeld, @holderName, @instanceId, @namespace, @resource + return @@LockState.Held, timePassed + + else -- Unknown: attempt to acquire + if mutex.tryLock! + @_held[1] = true + @logger\trace msgs.lock.attained, @holderName, @instanceId, @namespace, @resource + return @@LockState.Held, timePassed + + @logger\trace msgs.lock.heldByOther, @namespace, @resource, lockWaitInterval + Timer.sleep lockWaitInterval unless timeout == 0 + timePassed += lockWaitInterval + + @logger\trace msgs.lock.timeout, @namespace, @resource, @holderName, @instanceId + return @@LockState.Unavailable, timePassed + + --- Attempts to acquire the lock without waiting. + -- @return number LockState + -- @return number timePassed + tryLock: => + return @lock 0 + + --- Releases the lock held by this instance. + -- @return boolean|nil + -- @return string|nil err + release: => + unless @_held[1] + return nil, msgs.release.failed\format @namespace, @resource, @holderName, @instanceId, msgs.release.notHeld + mutex.unlock! + @_held[1] = false + @logger\trace msgs.release.released, @holderName, @instanceId, @namespace, @resource + return true, @@LockState.Available diff --git a/modules/DependencyControl/Logger.moon b/modules/DependencyControl/Logger.moon index 17a5abf..96038d4 100644 --- a/modules/DependencyControl/Logger.moon +++ b/modules/DependencyControl/Logger.moon @@ -1,6 +1,8 @@ -PreciseTimer = require "PT.PreciseTimer" +Timer = require "l0.DependencyControl.Timer" lfs = require "lfs" +--- Structured logger that writes to Aegisub's log window and optional log files. +-- @class Logger class Logger levels = {"fatal", "error", "warning", "hint", "debug", "trace"} defaultLevel: 2 @@ -18,7 +20,7 @@ class Logger indentStr: "—" maxFiles: 200, maxAge: 604800, maxSize:10*(10^6) - timer, seeded = PreciseTimer!, false + timer, seeded = Timer!, false new: (args) => if args @@ -41,6 +43,14 @@ class Logger @fileName = @fileTemplate\format aegisub.decode_path(@logDir), os.date("%Y-%m-%d-%H-%M-%S"), math.random(0, 16^4-1), @fileBaseName, @fileSubName + --- Writes a log message with explicit rendering options. + -- @param[opt] level number + -- @param[opt] msg string|table + -- @param[opt=true] insertLineFeed boolean + -- @param[opt] prefix string + -- @param[opt] indent number + -- @param[opt] ... any + -- @return boolean logEx: (level = @defaultLevel, msg = "", insertLineFeed = true, prefix = @prefix, indent = @indent, ...) => return false if msg == "" @@ -51,7 +61,7 @@ class Logger msg = if @lastHadLineFeed @format msg, indent, ... elseif 0 < select "#", ... - msg\format ... + (tostring msg)\format ... show = aegisub.log and @toWindow if @toFile and level <= @maxToFileLevel @@ -73,7 +83,7 @@ class Logger msg = table.concat msg, "\n" if 0 < select "#", ... - msg = msg\format ... + msg = (tostring msg)\format ... return msg unless indent>0 @@ -96,10 +106,23 @@ class Logger debug: (...) => @log 4, ... trace: (...) => @log 5, ... + --- Logs an error message when the given condition is falsy. + -- @param cond any + -- @param[opt] ... any + -- @return any assert: (cond, ...) => if not cond @log 1, ... - else return cond + else return cond, ... + + --- Logs an error message when the given condition is nil. + -- @param cond any + -- @param[opt] ... any + -- @return any + assertNotNil: (cond, ...) => + if cond == nil + @log 1, ... + else return cond, ... progress: (progress=false, msg = "", ...) => if @progressStep and not progress @@ -115,10 +138,20 @@ class Logger @progressStep = step -- taken from https://github.com/TypesettingCartel/Aegisub-Motion/blob/master/src/Log.moon - dump: ( item, ignore, level = @defaultLevel ) => - @log level, @dumpToString item, ignore - - dumpToString: ( item, ignore ) => + --- Logs a table dump (or scalar value) at the specified level. + -- @param item any + -- @param[opt] ignore any + -- @param[opt] level number + -- @param[opt] maxDepth number + dump: ( item, ignore, level = @defaultLevel, maxDepth ) => + @log level, @dumpToString item, ignore, maxDepth + + --- Converts a table dump (or scalar value) to a readable string. + -- @param item any + -- @param[opt] ignore any + -- @param[opt] maxDepth number + -- @return string + dumpToString: ( item, ignore, maxDepth ) => if "table" != type item return tostring item @@ -126,7 +159,14 @@ class Logger result = { "{ @#{tablecount}" } seen = { [item]: tablecount } - recurse = ( item, space ) -> + recurse = ( item, space, depth = 0 ) -> + if maxDepth and depth > maxDepth + count += 1 + result[count] = space .. "<...>" + return + + depth += 1 + for key, value in pairs item unless key == ignore if "number" == type key @@ -137,7 +177,7 @@ class Logger seen[value] = tablecount count += 1 result[count] = space .. "#{key}: { @#{tablecount}" - recurse value, space .. " " + recurse value, space .. " ", depth count += 1 result[count] = space .. "}" else @@ -183,3 +223,11 @@ class Logger else kept += 1 return total-kept, deletedSize, total, totalSize + + @describeType = (val) => + _type = type val + return _type unless _type == "table" + + return if val.__class + "#{val.__class.__name} object" + else _type diff --git a/modules/DependencyControl/ModuleLoader.moon b/modules/DependencyControl/ModuleLoader.moon index e1f6738..fc9bc20 100644 --- a/modules/DependencyControl/ModuleLoader.moon +++ b/modules/DependencyControl/ModuleLoader.moon @@ -1,172 +1,188 @@ --- Note: this is a private API intended to be exclusively for internal DependenyControl use --- Everyting in this class can and will change without any prior notice --- and calling any method is guaranteed to interfere with DepdencyControl operation - -class ModuleLoader - msgs = { - checkOptionalModules: { - downloadHint: "Please download the modules in question manually, put them in your %s folder and reload your automation scripts." - missing: "Error: a %s feature you're trying to use requires additional modules that were not found on your system:\n%s\n%s" - } - formatVersionErrorTemplate: { - missing: "— %s %s%s\n—— Reason: %s" - outdated: "— %s (Installed: v%s; Required: v%s)%s\n—— Reason: %s" - } - loadModules: { - missing: "Error: one or more of the modules required by %s could not be found on your system:\n%s\n%s" - missingRecord: "Error: module '%s' is missing a version record." - moduleError: "Error in required module %s:\n%s" - outdated: [[Error: one or more of the modules required by %s are outdated on your system: -%s\nPlease update the modules in question manually and reload your automation scripts.]] - } - } - - @formatVersionErrorTemplate = (name, reqVersion, url, reason, ref) => - url = url and ": #{url}" or "" - if ref - version = @@parseVersion ref.version - return msgs.formatVersionErrorTemplate.outdated\format name, version, reqVersion, url, reason - else - reqVersion = reqVersion and " (v#{reqVersion})" or "" - return msgs.formatVersionErrorTemplate.missing\format name, reqVersion, url, reason - - @createDummyRef = => - return nil if @scriptType != @@ScriptType.Module - -- global module registry allows for circular dependencies: - -- set a dummy reference to this module since this module is not ready - -- when the other one tries to load it (and vice versa) - export LOADED_MODULES = {} unless LOADED_MODULES - unless LOADED_MODULES[@namespace] - @ref = {} - LOADED_MODULES[@namespace] = setmetatable {__depCtrlDummy: true, version: @}, @ref - return true - return false - - @removeDummyRef = => - return nil if @scriptType != @@ScriptType.Module - if LOADED_MODULES[@namespace] and LOADED_MODULES[@namespace].__depCtrlDummy - LOADED_MODULES[@namespace] = nil - return true - return false - - @loadModule = (mdl, usePrivate, reload) => - runInitializer = (ref) -> - return unless type(ref) == "table" and ref.__depCtrlInit - -- Note to future self: don't change this to a class check! When DepCtrl self-updates - -- any managed module initialized before will still use the same instance - if type(ref.version) != "table" or ref.version.__name != @@__name - ref.__depCtrlInit @@ - - with mdl - ._missing, ._error = nil - - moduleName = usePrivate and "#{@namespace}.#{mdl.moduleName}" or .moduleName - name = "#{mdl.name or mdl.moduleName}#{usePrivate and ' (Private Copy)' or ''}" - - if .outdated or reload - -- clear old references - package.loaded[moduleName], LOADED_MODULES[moduleName] = nil - - elseif ._ref = LOADED_MODULES[moduleName] - -- module is already loaded, however it may or may not have been loaded by DepCtrl - -- so we have to call any DepCtrl initializer if it hasn't been called yet - runInitializer ._ref - return ._ref - - loaded, res = xpcall require, debug.traceback, moduleName - unless loaded - LOADED_MODULES[moduleName] = nil - res or= "unknown error" - ._missing = res\match "module '.+' not found:" - ._error = res unless ._missing - return nil - - -- set new references - if reload and ._ref and ._ref.__depCtrlDummy - setmetatable ._ref, res - ._ref, LOADED_MODULES[moduleName] = res, res - - -- run DepCtrl initializer if one was specified - runInitializer res - - return mdl._ref -- having this in the with block breaks moonscript - - @loadModules = (modules, addFeeds = {@feed}, skip = @moduleName and {[@moduleName]: true} or {}) => - for mdl in *modules - continue if skip[mdl] - with mdl - ._ref, ._updated, ._missing, ._outdated, ._reason, ._error = nil - - -- try to load private copies of required modules first - ModuleLoader.loadModule @, mdl, true - ModuleLoader.loadModule @, mdl unless ._ref - - -- try to fetch and load a missing module from the web - if ._missing - record = @@{moduleName:.moduleName, name:.name or .moduleName, - version:-1, url:.url, feed:.feed, virtual:true} - ._ref, code, extErr = @@updater\require record, .version, addFeeds, .optional - if ._ref or .optional - ._updated, ._missing = true, false - else - ._reason = @@updater\getUpdaterErrorMsg code, .name or .moduleName, true, true, extErr - -- nuke dummy reference for circular dependencies - LOADED_MODULES[.moduleName] = nil - - -- check if the version requirements are satisfied - -- which is guaranteed for modules updated with \require, so we don't need to check again - if .version and ._ref and not ._updated - record = ._ref.version - unless record - ._error = msgs.loadModules.missingRecord\format .moduleName - continue - - if type(record) != "table" or record.__class != @@ - record = @@ moduleName: .moduleName, version: record, recordType: @@RecordType.Unmanaged - - -- force an update for outdated modules - if not record\checkVersion .version - ref, code, extErr = @@updater\require record, .version, addFeeds - if ref - ._ref = ref - elseif not .optional - ._outdated = true - ._reason = @@updater\getUpdaterErrorMsg code, .name or .moduleName, true, false, extErr - else - -- perform regular update check if we can get a lock without waiting - -- right now we don't care about the result and don't reload the module - -- so the update will not be effective until the user restarts Aegisub - -- or reloads the script - @@updater\scheduleUpdate record - - missing, outdated, moduleError = {}, {}, {} - for mdl in *modules - with mdl - name = .name or .moduleName - if ._missing - missing[#missing+1] = ModuleLoader.formatVersionErrorTemplate @, name, .version, .url, ._reason - elseif ._outdated - outdated[#outdated+1] = ModuleLoader.formatVersionErrorTemplate @, name, .version, .url, ._reason, ._ref - elseif ._error - moduleError[#moduleError+1] = msgs.loadModules.moduleError\format name, ._error - - errorMsg = {} - if #moduleError > 0 - errorMsg[1] = table.concat moduleError, "\n" - if #outdated > 0 - errorMsg[#errorMsg+1] = msgs.loadModules.outdated\format @name, table.concat outdated, "\n" - if #missing > 0 - errorMsg[#errorMsg+1] = msgs.loadModules.missing\format @name, table.concat(missing, "\n"), downloadHint - - return #errorMsg == 0, table.concat(errorMsg, "\n\n") - - @checkOptionalModules = (modules) => - modules = type(modules)=="string" and {[modules]:true} or {mdl,true for mdl in *modules} - missing = [ModuleLoader.formatVersionErrorTemplate @, mdl.moduleName, mdl.version, msl.url, - mdl._reason for mdl in *@requiredModules when mdl.optional and mdl._missing and modules[mdl.name]] - - if #missing>0 - downloadHint = msgs.checkOptionalModules.downloadHint\format @@automationDir.modules - errorMsg = msgs.checkOptionalModules.missing\format @name, table.concat(missing, "\n"), downloadHint - return false, errorMsg - return true \ No newline at end of file +-- Note: this is a private API intended to be exclusively for internal DependenyControl use +-- Everything in this class can and will change without any prior notice +-- and calling any method is guaranteed to interfere with DependencyControl operation + +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" + +--- Internal module loading helpers for DependencyControl-managed module dependencies. +-- @class ModuleLoader +class ModuleLoader + msgs = { + checkOptionalModules: { + downloadHint: "Please download the modules in question manually, put them in your %s folder and reload your automation scripts." + missing: "Error: a %s feature you're trying to use requires additional modules that were not found on your system:\n%s\n%s" + } + formatVersionErrorTemplate: { + missing: "— %s %s%s\n—— Reason: %s" + outdated: "— %s (Installed: v%s; Required: v%s)%s\n—— Reason: %s" + } + loadModules: { + missing: "Error: one or more of the modules required by %s could not be found on your system:\n%s\n%s" + missingRecord: "Error: module '%s' is missing a version record." + moduleError: "Error in required module %s:\n%s" + outdated: [[Error: one or more of the modules required by %s are outdated on your system: +%s\nPlease update the modules in question manually and reload your automation scripts.]] + } + } + + @formatVersionErrorTemplate = (name, reqVersion, url, reason, ref) => + url = url and ": #{url}" or "" + if ref + -- unmanaged records have refs whose .version is a string instead of a DepCtrl record + version = SemanticVersioning\toString type(ref.version) == "table" and ref.version.version or ref.version + return msgs.formatVersionErrorTemplate.outdated\format name, version, reqVersion, url, reason + else + reqVersion = reqVersion and " (v#{reqVersion})" or "" + return msgs.formatVersionErrorTemplate.missing\format name, reqVersion, url, reason + + @createDummyRef = => + return nil if @scriptType != @@ScriptType.Module + -- global module registry allows for circular dependencies: + -- set a dummy reference to this module since this module is not ready + -- when the other one tries to load it (and vice versa) + export LOADED_MODULES = {} unless LOADED_MODULES + unless LOADED_MODULES[@namespace] + @ref = {} + LOADED_MODULES[@namespace] = setmetatable {__depCtrlDummy: true, version: @}, @ref + return true + return false + + @removeDummyRef = => + return nil if @scriptType != @@ScriptType.Module + if LOADED_MODULES[@namespace] and LOADED_MODULES[@namespace].__depCtrlDummy + LOADED_MODULES[@namespace] = nil + return true + return false + + @loadModule = (mdl, usePrivate, reload) => + runInitializer = (ref) -> + return unless type(ref) == "table" and ref.__depCtrlInit + -- Note to future self: don't change this to a class check! When DepCtrl self-updates + -- any managed module initialized before will still use the same instance + if type(ref.version) != "table" or not (ref.version.__class and ref.version.__class.__name == @@__name) + ref.__depCtrlInit @@ + + with mdl + ._missing, ._error = nil + + moduleName = usePrivate and "#{@namespace}.#{mdl.moduleName}" or .moduleName + name = "#{mdl.name or mdl.moduleName}#{usePrivate and ' (Private Copy)' or ''}" + + if .outdated or reload + -- clear old references + package.loaded[moduleName], LOADED_MODULES[moduleName] = nil + + elseif ._ref = LOADED_MODULES[moduleName] + -- module is already loaded, however it may or may not have been loaded by DepCtrl + -- so we have to call any DepCtrl initializer if it hasn't been called yet + runInitializer ._ref + return ._ref + + loaded, res = xpcall require, debug.traceback, moduleName + unless loaded + LOADED_MODULES[moduleName] = nil + res or= "unknown error" + ._missing = nil != res\find "module '#{moduleName}' not found:", nil, true + ._error = res unless ._missing + return nil + + -- set new references + if reload and ._ref and ._ref.__depCtrlDummy + setmetatable ._ref, res + ._ref, LOADED_MODULES[moduleName] = res, res + + -- run DepCtrl initializer if one was specified + runInitializer res + + return mdl._ref -- having this in the with block breaks moonscript + + --- Loads required modules, updates missing/outdated ones, and validates version constraints. + -- @param modules table[] + -- @param[opt] addFeeds string[] + -- @param[opt] skip table + -- @return boolean + -- @return string err + @loadModules = (modules, addFeeds = {@feed}, skip = @moduleName and {[@moduleName]: true} or {}) => + for mdl in *modules + continue if skip[mdl.moduleName] + with mdl + ._ref, ._updated, ._missing, ._outdated, ._reason, ._error = nil + + -- try to load private copies of required modules first + ModuleLoader.loadModule @, mdl, true + ModuleLoader.loadModule @, mdl unless ._ref + + -- try to fetch and load a missing module from the web + if ._missing + record = @@{moduleName:.moduleName, name:.name or .moduleName, + version:-1, url:.url, feed:.feed, virtual:true} + ._ref, code, extErr = @@updater\require record, .version, addFeeds, .optional + if ._ref or .optional + ._updated, ._missing = true, false + else + ._reason = @@updater\getUpdaterErrorMsg code, .name or .moduleName, true, true, extErr + -- nuke dummy reference for circular dependencies + LOADED_MODULES[.moduleName] = nil + + -- check if the version requirements are satisfied + -- which is guaranteed for modules updated with \require, so we don't need to check again + if .version and ._ref and not ._updated + record = ._ref.version + unless record + ._error = msgs.loadModules.missingRecord\format .moduleName + continue + + if type(record) != "table" or record.__class != @@ + record = @@ moduleName: .moduleName, version: record, recordType: @@RecordType.Unmanaged + + -- force an update for outdated modules + if not record\checkVersion .version + ref, code, extErr = @@updater\require record, .version, addFeeds + if ref + ._ref = ref + elseif not .optional + ._outdated = true + ._reason = @@updater\getUpdaterErrorMsg code, .name or .moduleName, true, false, extErr + else + -- perform regular update check if we can get a lock without waiting + -- right now we don't care about the result and don't reload the module + -- so the update will not be effective until the user restarts Aegisub + -- or reloads the script + @@updater\scheduleUpdate record + + missing, outdated, moduleError = {}, {}, {} + for mdl in *modules + with mdl + name = .name or .moduleName + if ._missing + missing[#missing+1] = ModuleLoader.formatVersionErrorTemplate @, name, .version, .url, ._reason + elseif ._outdated + outdated[#outdated+1] = ModuleLoader.formatVersionErrorTemplate @, name, .version, .url, ._reason, ._ref + elseif ._error + moduleError[#moduleError+1] = msgs.loadModules.moduleError\format name, ._error + + errorMsg = {} + if #moduleError > 0 + errorMsg[1] = table.concat moduleError, "\n" + if #outdated > 0 + errorMsg[#errorMsg+1] = msgs.loadModules.outdated\format @name, table.concat outdated, "\n" + if #missing > 0 + downloadHint = msgs.checkOptionalModules.downloadHint\format @@automationDir.modules + errorMsg[#errorMsg+1] = msgs.loadModules.missing\format @name, table.concat(missing, "\n"), downloadHint + + return #errorMsg == 0, table.concat(errorMsg, "\n\n") + + --- Validates optional module availability for the requested feature set. + -- @param modules string|string[] + -- @return boolean + -- @return string|nil err + @checkOptionalModules = (modules) => + modules = type(modules)=="string" and {[modules]:true} or {mdl,true for mdl in *modules} + missing = [ModuleLoader.formatVersionErrorTemplate @, mdl.moduleName, mdl.version, mdl.url, + mdl._reason for mdl in *@requiredModules when mdl.optional and mdl._missing and modules[mdl.name]] + + if #missing>0 + downloadHint = msgs.checkOptionalModules.downloadHint\format @@automationDir.modules + errorMsg = msgs.checkOptionalModules.missing\format @name, table.concat(missing, "\n"), downloadHint + return false, errorMsg + return true diff --git a/modules/DependencyControl/ModuleProvider.moon b/modules/DependencyControl/ModuleProvider.moon new file mode 100644 index 0000000..e47ea2e --- /dev/null +++ b/modules/DependencyControl/ModuleProvider.moon @@ -0,0 +1,58 @@ +-- Resolves provided module aliases (e.g. "json") to their provider module +-- (e.g. "l0.dkjson") through a custom package searcher. +-- +-- A module declares the aliases it can satisfy via its record's `provides` field; +-- DependencyControl registers those here, and a single searcher — appended last so +-- stock searchers and any real user-supplied module always win first — lazily loads +-- the provider when an otherwise-unresolved alias is required. +-- +-- State lives in a global table so registrations and the installed searcher survive +-- DependencyControl self-update reloads. +-- @class ModuleProvider + +GLOBAL_KEY = "__depCtrlModuleProvider" + +state = _G[GLOBAL_KEY] +unless state + state = { providers: {}, installed: false } + _G[GLOBAL_KEY] = state + +-- Lua module searcher: returns a loader for a registered alias, otherwise nil. +-- Kept to a single hash lookup since it runs for every otherwise-unresolved require. +search = (name) -> + providerName = state.providers[name] + return unless providerName + -> require providerName + +class ModuleProvider + --- Registers a provider for an alias name. First registration wins. + -- @param alias string the (possibly bare) module name to provide + -- @param providerName string the namespaced module that provides it + -- @return boolean whether the registration was applied + @register = (alias, providerName) -> + return false unless type(alias) == "string" and type(providerName) == "string" + return false if state.providers[alias] + state.providers[alias] = providerName + return true + + --- Registers every alias declared in a record's `provides` field. + -- @param record table a record with .moduleName and an optional .provides array + @registerRecord = (record) -> + return unless record.provides and record.moduleName + for alias in *record.provides + name = type(alias) == "table" and alias.name or alias + @register name, record.moduleName if name + + --- Gets the provider namespace registered for an alias module name. + -- @param alias string + -- @return string|nil the provider namespace registered for the alias + @getProvider = (alias) -> state.providers[alias] + + --- Installs the alias searcher. Idempotent across reloads. + @install = -> + return if state.installed + loaders = package.loaders or package.searchers + loaders[#loaders + 1] = search + state.installed = true + +return ModuleProvider diff --git a/modules/DependencyControl/Record.moon b/modules/DependencyControl/Record.moon index 5293321..bcf61b8 100644 --- a/modules/DependencyControl/Record.moon +++ b/modules/DependencyControl/Record.moon @@ -1,315 +1,377 @@ -json = require "json" -lfs = require "lfs" -re = require "aegisub.re" - -Common = require "l0.DependencyControl.Common" -Logger = require "l0.DependencyControl.Logger" -ConfigHandler = require "l0.DependencyControl.ConfigHandler" -FileOps = require "l0.DependencyControl.FileOps" -Updater = require "l0.DependencyControl.Updater" -ModuleLoader = require "l0.DependencyControl.ModuleLoader" -SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" - -class Record extends Common - namespaceValidation = re.compile "^(?:[-\\w]+\\.)+[-\\w]+$" - - msgs = { - new: { - badRecordError: "Error: Bad #{@@__name} record (%s)." - badRecord: { - noUnmanagedMacros: "Creating unmanaged version records for macros is not allowed" - missingNamespace: "No namespace defined" - badVersion: "Couldn't parse version number: %s" - badNamespace: "Namespace '%s' failed validation. Namespace rules: must contain 1+ single dots, but not start or end with a dot; all other characters must be in [A-Za-z0-9-_]." - badModuleTable: "Invalid required module table #%d (%s)." - } - } - uninstall: { - noVirtualOrUnmanaged: "Can't uninstall %s %s '%s'. (Only installed scripts managed by #{@@__name} can be uninstalled)." - } - writeConfig: { - error: "An error occured while writing the #{@@__name} config file: %s" - writing: "Writing updated %s data to config file..." - } - } - - @depConf = { - file: aegisub.decode_path "?user/config/l0.#{@@__name}.json", - scriptFields: {"author", "configFile", "feed", "moduleName", "name", "namespace", "url", -- REMOVE - "requiredModules", "version", "unmanaged"}, - globalDefaults: {updaterEnabled:true, updateInterval:302400, traceLevel:3, extraFeeds:{}, - tryAllFeeds:false, dumpFeeds:true, configDir:"?user/config", - logMaxFiles: 200, logMaxAge: 604800, logMaxSize:10*(10^6), - updateWaitTimeout: 60, updateOrphanTimeout: 600, - logDir: "?user/log", writeLogs: true} - } - - init = => - FileOps.mkdir @depConf.file, true - @loadConfig! - @logger = Logger { fileBaseName: "DepCtrl", fileSubName: script_namespace, prefix: "[#{@@__name}] ", - toFile: @config.c.writeLogs, defaultLevel: @config.c.traceLevel, - maxAge: @config.c.logMaxAge,maxSize: @config.c.logMaxSize, maxFiles: @config.c.logMaxFiles, - logDir: @config.c.logDir } - - @updater = Updater script_namespace, @config, @logger - @configDir = @config.c.configDir - - FileOps.mkdir aegisub.decode_path @configDir - logsHaveBeenTrimmed or= @logger\trimFiles! - FileOps.runScheduledRemoval @configDir - - - new: (args) => - init Record unless @@logger - - -- defaults - args[k] = v for k, v in pairs { - readGlobalScriptVars: true - saveRecordToConfig: true - } when args[k] == nil - - {@requiredModules, moduleName:@moduleName, configFile:configFile, virtual:@virtual, :name, - description:@description, url:@url, feed:@feed, recordType:@recordType, :namespace, - author:@author, :version, configFile:@configFile, - :readGlobalScriptVars, :saveRecordToConfig} = args - - @recordType or= @@RecordType.Managed - -- also support name key (as used in configuration) for required modules - @requiredModules or= args.requiredModules - - if @moduleName - @namespace = @moduleName - @name = name or @moduleName - @scriptType = @@ScriptType.Module - ModuleLoader.createDummyRef @ unless @virtual or @recordType == @@RecordType.Unmanaged - - else - if @virtual or not readGlobalScriptVars - @name = name or namespace - @namespace = namespace - version or= 0 - else - @name = name or script_name - @description or= script_description - @author or= script_author - version or= script_version - - @namespace = namespace or script_namespace - assert @recordType == @@RecordType.Managed, msgs.new.badRecordError\format msgs.new.badRecord.noUnmanagedMacros - assert @namespace, msgs.new.badRecordError\format msgs.new.badRecord.missingNamespace - @scriptType = @@ScriptType.Automation - - -- if the hosting macro doesn't have a namespace defined, define it for - -- the first DepCtrled module loaded by the macro or its required modules - unless script_namespace - export script_namespace = @namespace - - -- non-depctrl record don't need to conform to namespace rules - assert @virtual or @recordType == @@RecordType.Unmanaged or @validateNamespace!, - msgs.new.badRecord.badNamespace\format @namespace - - @configFile = configFile or "#{@namespace}.json" - @automationDir = @@automationDir[@scriptType] - @testDir = @@testDir[@scriptType] - @version, err = @@parseVersion version - assert @version, msgs.new.badRecordError\format msgs.new.badRecord.badVersion\format err - - @requiredModules or= {} - -- normalize short format module tables - for i, mdl in pairs @requiredModules - switch type mdl - when "table" - mdl.moduleName or= mdl[1] - mdl[1] = nil - when "string" - @requiredModules[i] = {moduleName: mdl} - else error msgs.new.badRecordError\format msgs.new.badRecord.badModuleTable\format i, tostring mdl - - shouldWriteConfig = @loadConfig! - - -- write config file if contents are missing or are out of sync with the script version record - -- ramp up the random wait time on first initialization (many scripts may want to write configuration data) - -- we can't really profit from write concerting here because we don't know which module loads last - @writeConfig if shouldWriteConfig and saveRecordToConfig - - checkOptionalModules: ModuleLoader.checkOptionalModules - - -- loads the DependencyControl global configuration - @loadConfig = => - if @config - @config\load! - else @config = ConfigHandler @depConf.file, @depConf.globalDefaults, {"config"}, nil, @logger - - -- loads the script configuration - loadConfig: (importRecord = false) => - -- virtual modules are not yet present on the user's system and have no persistent configuration - @config or= ConfigHandler not @virtual and @@depConf.file, {}, - { @@ScriptType.name.legacy[@scriptType], @namespace }, true, @@logger - - -- import and overwrites version record from the configuration - if importRecord - -- check if a module that was previously virtual was installed in the meantime - -- TODO: prevent issues caused by orphaned config entries - haveConfig = false - if @virtual - @config\setFile @@depConf.file - if @config\load! - haveConfig, @virtual = true, false - else @config\unsetFile! - else - haveConfig = @config\load! - - -- only need to refresh data if the record was changed by an update - if haveConfig - @[key] = @config.c[key] for key in *@@depConf.scriptFields - - elseif not @virtual - -- copy script information to the config - @config\load! - shouldWriteConfig = @config\import @, @@depConf.scriptFields, false, true - return shouldWriteConfig - - return false - - writeConfig: => - unless @virtual or @config.file - @config\setFile @@depConf.file - - @@logger\trace msgs.writeConfig.writing, @@terms.scriptType.singular[@scriptType] - @config\import @, @@depConf.scriptFields, false, true - success, errMsg = @config\write false - - assert success, msgs.writeConfig.error\format errMsg - - - @parseVersion = SemanticVersioning.parse - - - @getVersionString = SemanticVersioning.toString - - - getConfigFileName: () => - return aegisub.decode_path "#{@@configDir}/#{@configFile}" - - getConfigHandler: (defaults, section, noLoad) => - return ConfigHandler @getConfigFileName!, defaults, section, noLoad - - getLogger: (args = {}) => - args.fileBaseName or= @namespace - args.toFile = @config.c.logToFile if args.toFile == nil - args.defaultLevel or= @config.c.logLevel - args.prefix or= @moduleName and "[#{@name}]" - - return Logger args - - checkVersion: (value, precision = "patch") => - if type(value) == "table" and value.__class == @@ - value = value.version - return SemanticVersioning\check @version, value - - - getSubmodules: => - return nil if @virtual or @recordType == @@RecordType.Unmanaged or @scriptType != @@ScriptType.Module - mdlConfig = @@config\getSectionHandler @@ScriptType.name.legacy[@@ScriptType.Module] - pattern = "^#{@namespace}."\gsub "%.", "%%." - return [mdl for mdl, _ in pairs mdlConfig.c when mdl\match pattern], mdlConfig - - requireModules: (modules = @requiredModules, addFeeds = {@feed}) => - success, err = ModuleLoader.loadModules @, modules, addFeeds - @@updater\releaseLock! - unless success - -- if we failed loading our required modules - -- then that means we also failed to load - LOADED_MODULES[@namespace] = nil - @@logger\error err - return unpack [mdl._ref for mdl in *modules] - - registerTests: (...) => - -- load external tests - haveTests, tests = pcall require, "DepUnit.#{@@ScriptType.name.legacy[@scriptType]}.#{@namespace}" - - if haveTests and not @testsLoaded - @tests, tests.name = tests, @name - modules = table.pack @requireModules! - if @moduleName - @tests\import @ref, modules, ... - else @tests\import modules, ... - - @tests\registerMacros! - @testsLoaded = true - - register: (selfRef, ...) => - -- replace dummy refs with real refs to own module - @ref.__index, @ref, LOADED_MODULES[@moduleName] = selfRef, selfRef, selfRef - @registerTests selfRef, ... - return selfRef - - registerMacro: (name=@name, description=@description, process, validate, isActive, submenu) => - -- alternative signature takes name and description from script - if type(name)=="function" - process, validate, isActive, submenu = name, description, process, validate - name, description = @name, @description - - -- use automation script name for submenu by default - submenu = @name if submenu == true - - menuName = { @config.c.customMenu } - menuName[#menuName+1] = submenu if submenu - menuName[#menuName+1] = name - - -- check for updates before running a macro - processHooked = (sub, sel, act) -> - @@updater\scheduleUpdate @ - @@updater\releaseLock! - return process sub, sel, act - - aegisub.register_macro table.concat(menuName, "/"), description, processHooked, validate, isActive - - registerMacros: (macros = {}, submenuDefault = true) => - for macro in *macros - -- allow macro table to omit name and description - submenuIdx = type(macro[1])=="function" and 4 or 6 - macro[submenuIdx] = submenuDefault if macro[submenuIdx] == nil - @registerMacro unpack(macro, 1, 6) - - setVersion: (version) => - version, err = @@parseVersion version - if version - @version = version - return version - else return nil, err - - validateNamespace: (namespace = @namespace, isVirtual = @virtual) => - return isVirtual or namespaceValidation\match @namespace - - uninstall: (removeConfig = true) => - if @virtual or @recordType == @@RecordType.Unmanaged - return nil, msgs.uninstall.noVirtualOrUnmanaged\format @virtual and "virtual" or "unmanaged", - @@terms.scriptType.singular[@scriptType], - @name - @config\delete! - subModules, mdlConfig = @getSubmodules! - -- uninstalling a module also removes all submodules - if subModules and #subModules > 0 - mdlConfig.c[mdl] = nil for mdl in *subModules - mdlConfig\write! - - toRemove, pattern, dir = {} - if @moduleName - nsp, name = @namespace\match "(.+)%.(.+)" - pattern = "^#{name}" - dir = "#{@automationDir}/#{nsp\gsub '%.', '/'}" - else - pattern = "^#{@namespace}"\gsub "%.", "%%." - dir = @automationDir - - lfs.chdir dir - for file in lfs.dir dir - mode, path = FileOps.attributes file, "mode" - -- parent level module files must be .ext - currPattern = @moduleName and mode == "file" and pattern.."%." or pattern - -- automation scripts don't use any subdirectories - if (@moduleName or mode == "file") and file\match currPattern - toRemove[#toRemove+1] = path - return FileOps.remove toRemove, true, true \ No newline at end of file +json = require "json" +lfs = require "lfs" + +Common = require "l0.DependencyControl.Common" +Logger = require "l0.DependencyControl.Logger" +ConfigView = require "l0.DependencyControl.ConfigView" +FileOps = require "l0.DependencyControl.FileOps" +Updater = require "l0.DependencyControl.Updater" +ModuleLoader = require "l0.DependencyControl.ModuleLoader" +ModuleProvider = require "l0.DependencyControl.ModuleProvider" +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" + +--- DependencyControl record representing one managed or unmanaged script/module. +-- @class Record +class Record extends Common + msgs = { + new: { + badRecordError: "Error: Bad #{@@__name} record (%s)." + badRecord: { + noUnmanagedMacros: "Creating unmanaged version records for macros is not allowed" + missingNamespace: "No namespace defined" + badVersion: "Couldn't parse version number: %s" + badNamespace: "Namespace '%s' failed validation. Namespace rules: must contain 1+ single dots, but not start or end with a dot; all other characters must be in [A-Za-z0-9-_]." + badModuleTable: "Invalid required module table #%d (%s)." + } + } + uninstall: { + noVirtualOrUnmanaged: "Can't uninstall %s %s '%s'. (Only installed scripts managed by #{@@__name} can be uninstalled)." + } + writeConfig: { + error: "An error occured while writing the #{@@__name} config file: %s" + writing: "Writing updated %s data to config file..." + } + } + + @depConf = { + file: aegisub.decode_path "?user/config/l0.#{@@__name}.json", + scriptFields: {"author", "configFile", "feed", "moduleName", "name", "namespace", "url", -- REMOVE + "requiredModules", "version", "unmanaged", "provides"}, + globalDefaults: {updaterEnabled:true, updateInterval:302400, traceLevel:3, extraFeeds:{}, + tryAllFeeds:false, dumpFeeds:true, configDir:"?user/config", + logMaxFiles: 200, logMaxAge: 604800, logMaxSize:10*(10^6), + updateWaitTimeout: 60, updateOrphanTimeout: 600, + logDir: "?user/log", writeLogs: true} + } + + init = => + FileOps.mkdir @depConf.file, true + @loadConfig! + @logger = Logger { fileBaseName: "DepCtrl", fileSubName: script_namespace, prefix: "[#{@@__name}] ", + toFile: @config.c.writeLogs, defaultLevel: @config.c.traceLevel, + maxAge: @config.c.logMaxAge,maxSize: @config.c.logMaxSize, maxFiles: @config.c.logMaxFiles, + logDir: @config.c.logDir } + + @updater = Updater script_namespace, @config, @logger + @configDir = @config.c.configDir + + FileOps.mkdir aegisub.decode_path @configDir + logsHaveBeenTrimmed or= @logger\trimFiles! + FileOps.runScheduledRemoval @configDir + + + --- Creates a DependencyControl record from explicit arguments and/or script globals. + -- @param args table + new: (args) => + init Record unless @@logger + + -- defaults + args[k] = v for k, v in pairs { + readGlobalScriptVars: true + saveRecordToConfig: true + } when args[k] == nil + + {@requiredModules, moduleName:@moduleName, configFile:configFile, virtual:@virtual, :name, + description:@description, url:@url, feed:@feed, recordType:@recordType, :namespace, + author:@author, :version, configFile:@configFile, :provides, + :readGlobalScriptVars, :saveRecordToConfig} = args + + @recordType or= @@RecordType.Managed + -- also support name key (as used in configuration) for required modules + @requiredModules or= args.requiredModules + + if @moduleName + @namespace = @moduleName + @name = name or @moduleName + @scriptType = @@ScriptType.Module + ModuleLoader.createDummyRef @ unless @virtual or @recordType == @@RecordType.Unmanaged + + else + if @virtual or not readGlobalScriptVars + @name = name or namespace + @namespace = namespace + version or= 0 + else + @name = name or script_name + @description or= script_description + @author or= script_author + version or= script_version + + @namespace = namespace or script_namespace + assert @recordType == @@RecordType.Managed, msgs.new.badRecordError\format msgs.new.badRecord.noUnmanagedMacros + assert @namespace, msgs.new.badRecordError\format msgs.new.badRecord.missingNamespace + @scriptType = @@ScriptType.Automation + + -- if the hosting macro doesn't have a namespace defined, define it for + -- the first DepCtrled module loaded by the macro or its required modules + unless script_namespace + export script_namespace = @namespace + + -- non-depctrl record don't need to conform to namespace rules + assert @virtual or @recordType == @@RecordType.Unmanaged or @validateNamespace!, + msgs.new.badRecord.badNamespace\format @namespace + + @configFile = configFile or "#{@namespace}.json" + @automationDir = @@automationDir[@scriptType] + @testDir = @@testDir[@scriptType] + @version, err = SemanticVersioning\toNumber version + assert @version, msgs.new.badRecordError\format msgs.new.badRecord.badVersion\format err + + @requiredModules or= {} + -- normalize short format module tables + for i, mdl in pairs @requiredModules + switch type mdl + when "table" + mdl.moduleName or= mdl[1] + mdl[1] = nil + when "string" + @requiredModules[i] = {moduleName: mdl} + else error msgs.new.badRecordError\format msgs.new.badRecord.badModuleTable\format i, tostring mdl + + -- normalize `provides` aliases (bare string -> {name: …}) and register them so + -- `require`-ing a provided alias resolves to this module (see ModuleProvider) + if @provides + @provides = [type(alias) == "table" and alias or {name: alias} for alias in *@provides] + ModuleProvider.registerRecord @ + + shouldWriteConfig = @loadConfig! + + -- write config file if contents are missing or are out of sync with the script version record + -- ramp up the random wait time on first initialization (many scripts may want to write configuration data) + -- we can't really profit from write concerting here because we don't know which module loads last + @writeConfig if shouldWriteConfig and saveRecordToConfig + + checkOptionalModules: ModuleLoader.checkOptionalModules + + --- Loads global DependencyControl configuration. + -- @return ConfigView + @loadConfig = => + if @config + @config\load! + else @config = ConfigView\get @depConf.file, {"config"}, @depConf.globalDefaults, @logger + + --- Loads this record's script/module configuration hive. + -- @param[opt=false] importRecord boolean + -- @return boolean + loadConfig: (importRecord = false) => + -- virtual modules are not yet present on the user's system and have no persistent configuration + @config or= ConfigView\get not @virtual and @@depConf.file, + { @@ScriptType.name.legacy[@scriptType], @namespace }, {}, @@logger, true + + -- import and overwrites version record from the configuration + if importRecord + -- check if a module that was previously virtual was installed in the meantime + -- TODO: prevent issues caused by orphaned config entries + haveConfig = false + if @virtual + @config\setFile @@depConf.file + if @config\load! + haveConfig, @virtual = true, false + else @config\unsetFile! + else + haveConfig = @config\load! + + -- only need to refresh data if the record was changed by an update + if haveConfig + @[key] = @config.c[key] for key in *@@depConf.scriptFields + + elseif not @virtual + -- copy script information to the config + @config\load! + shouldWriteConfig = @config\import @, @@depConf.scriptFields, false, true + return shouldWriteConfig + + return false + + --- Writes this record's persisted fields to the shared config file. + -- @return nil + writeConfig: => + unless @virtual or @config.file + @config\setFile @@depConf.file + + @@logger\trace msgs.writeConfig.writing, @@terms.scriptType.singular[@scriptType] + @config\import @, @@depConf.scriptFields, false, true + success, errMsg = @config\save! + + assert success, msgs.writeConfig.error\format errMsg + + + -- retained for compatibility with DepCtrl <= v0.6.3 + -- TODO: deprecate w/ v0.7.0 and remove in next major release + @getVersionNumber = SemanticVersioning.toNumber + @getVersionString = SemanticVersioning.toString + + + --- Resolves this record's external config file path. + -- @return string + getConfigFileName: () => + return aegisub.decode_path "#{@@configDir}/#{@configFile}" + + --- Creates a ConfigView for this record's script-specific config file. + -- @param[opt] defaults table + -- @param[opt] section string|string[] + -- @param[opt] noLoad boolean + -- @return ConfigView + getConfigHandler: (defaults, section, noLoad) => + return ConfigView\get @getConfigFileName!, section, defaults, nil, noLoad + + --- Creates a logger preconfigured for this record. + -- @param[opt] args table + -- @return Logger + getLogger: (args = {}) => + args.fileBaseName or= @namespace + args.toFile = @config.c.logToFile if args.toFile == nil + args.defaultLevel or= @config.c.logLevel + args.prefix or= @moduleName and "[#{@name}]" + + return Logger args + + --- Checks whether this record's version satisfies a minimum version. + -- @param value number|string|Record + -- @param[opt="patch"] precision SemverPrecision + -- @return boolean|nil + -- @return number|string|nil + checkVersion: (value, precision = "patch") => + if type(value) == "table" and value.__class == @@ + value = value.version + return SemanticVersioning\check @version, value + + + --- Retrieves managed submodules registered under this module namespace. + -- @return string[]|nil + -- @return ConfigView|nil + getSubmodules: => + return nil if @virtual or @recordType == @@RecordType.Unmanaged or @scriptType != @@ScriptType.Module + mdlConfig = @@config\getSectionHandler @@ScriptType.name.legacy[@@ScriptType.Module] + pattern = "^#{@namespace}."\gsub "%.", "%%." + return [mdl for mdl, _ in pairs mdlConfig.c when mdl\match pattern], mdlConfig + + --- Loads or updates required modules and returns their references. + -- @param[opt] modules table[] + -- @param[opt] addFeeds string[] + -- @return ... any + requireModules: (modules = @requiredModules, addFeeds = {@feed}) => + success, err = ModuleLoader.loadModules @, modules, addFeeds + @@updater\releaseLock! + unless success + -- if we failed loading our required modules + -- then that means we also failed to load + LOADED_MODULES[@namespace] = nil + @@logger\error err + return unpack [mdl._ref for mdl in *modules] + + --- Registers DepUnit tests for this record if test modules are available. + -- @param[opt] ... any + registerTests: (...) => + -- load external tests + haveTests, tests = pcall require, "DepUnit.#{@@ScriptType.name.legacy[@scriptType]}.#{@namespace}" + + if haveTests and not @testsLoaded + @tests, tests.name = tests, @name + modules = table.pack @requireModules! + if @moduleName + @tests\import @ref, modules, ... + else @tests\import modules, ... + + @tests\registerMacros! + @testsLoaded = true + + --- Finalizes module registration and swaps dummy module refs for real refs. + -- @param selfRef table + -- @param[opt] ... any + -- @return table + register: (selfRef, ...) => + -- replace dummy refs with real refs to own module + @ref.__index, @ref, LOADED_MODULES[@moduleName] = selfRef, selfRef, selfRef + @registerTests selfRef, ... + return selfRef + + --- Registers a single Aegisub macro with DependencyControl update hooks. + -- @param[opt] name string|function + -- @param[opt] description string|function + -- @param process function + -- @param[opt] validate function + -- @param[opt] isActive function + -- @param[opt] submenu string|boolean + registerMacro: (name=@name, description=@description, process, validate, isActive, submenu) => + -- alternative signature takes name and description from script + if type(name)=="function" + process, validate, isActive, submenu = name, description, process, validate + name, description = @name, @description + + -- use automation script name for submenu by default + submenu = @name if submenu == true + + menuName = { @config.c.customMenu } + menuName[#menuName+1] = submenu if submenu + menuName[#menuName+1] = name + + -- check for updates before running a macro + processHooked = (sub, sel, act) -> + @@updater\scheduleUpdate @ + @@updater\releaseLock! + return process sub, sel, act + + aegisub.register_macro table.concat(menuName, "/"), description, processHooked, validate, isActive + + --- Registers multiple macros declared in table form. + -- @param[opt] macros table[] + -- @param[opt=true] submenuDefault boolean + registerMacros: (macros = {}, submenuDefault = true) => + for macro in *macros + -- allow macro table to omit name and description + submenuIdx = type(macro[1])=="function" and 4 or 6 + macro[submenuIdx] = submenuDefault if macro[submenuIdx] == nil + @registerMacro unpack(macro, 1, 6) + + --- Parses and sets this record's semantic version. + -- @param version number|string + -- @return number|nil + -- @return string|nil err + setVersion: (version) => + version, err = SemanticVersioning\toNumber version + if version + @version = version + return version + else return nil, err + + --- Validates this record's namespace, always passing for virtual records. + -- @return boolean + validateNamespace: => + return true if @virtual + return Common.validateNamespace @namespace + + --- Uninstalls this managed record and removes matching files from automation paths. + -- @param[opt=true] removeConfig boolean + -- @return boolean|nil + -- @return table|string|nil + uninstall: (removeConfig = true) => + if @virtual or @recordType == @@RecordType.Unmanaged + return nil, msgs.uninstall.noVirtualOrUnmanaged\format @virtual and "virtual" or "unmanaged", + @@terms.scriptType.singular[@scriptType], + @name + @config\delete! + subModules, mdlConfig = @getSubmodules! + -- uninstalling a module also removes all submodules + if subModules and #subModules > 0 + mdlConfig.c[mdl] = nil for mdl in *subModules + mdlConfig\write! + + toRemove, pattern, dir = {} + if @moduleName + nsp, name = @namespace\match "(.+)%.(.+)" + pattern = "^#{name}" + dir = "#{@automationDir}/#{nsp\gsub '%.', '/'}" + else + pattern = "^#{@namespace}"\gsub "%.", "%%." + dir = @automationDir + + lfs.chdir dir + for file in lfs.dir dir + mode, path = FileOps.attributes file, "mode" + -- parent level module files must be .ext + currPattern = @moduleName and mode == "file" and pattern.."%." or pattern + -- automation scripts don't use any subdirectories + if (@moduleName or mode == "file") and file\match currPattern + toRemove[#toRemove+1] = path + return FileOps.remove toRemove, true, true diff --git a/modules/DependencyControl/ScriptUpdateRecord.moon b/modules/DependencyControl/ScriptUpdateRecord.moon new file mode 100644 index 0000000..4d08fce --- /dev/null +++ b/modules/DependencyControl/ScriptUpdateRecord.moon @@ -0,0 +1,150 @@ +Logger = require "l0.DependencyControl.Logger" +Common = require "l0.DependencyControl.Common" +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" + +defaultLogger = Logger fileBaseName: "DepCtrl.ScriptUpdateRecord" + +---@class FeedFileData +---@field name string Filename relative to the base URL. +---@field url? string Absolute download URL after template variable expansion. +---@field platform? string Target platform filter (e.g. "Windows-x64"); absent means all platforms. + +---@class FeedChannelData +---@field version string Semantic version string of this release. +---@field files? FeedFileData[] Files provided by this release. +---@field platforms? string[] Platforms supported by this channel; absent means all platforms. +---@field default? boolean Whether this is the default channel. +---@field released? string Human-readable release date. +---@field fileBaseUrl? string Base URL prepended to file names during template expansion. + +---@class FeedScriptData +---@field name string Display name of the script. +---@field channels table Available update channels keyed by channel name. +---@field changelog? table Version-keyed changelog entries; values are a single string or a list of strings. +---@field author? string Script author. +---@field url? string Project or homepage URL. +---@field feed? string URL of the script's primary update feed. + +---@class FeedData +---@field name? string Display name of the feed. +---@field baseUrl? string Base URL used for template variable expansion across all entries. +---@field knownFeeds? table Named registry of other feed URLs for cross-feed references. +---@field macros table Automation scripts indexed by namespace. +---@field modules table Modules indexed by namespace. + +--- Feed-specific update information for a single script in a selected channel. +-- Fields from @{FeedScriptData} (name, changelog, etc.) are accessible directly on +-- the instance via __index fallback to the @data table. +-- Fields from the active @{FeedChannelData} (version, files, platforms, etc.) are +-- copied onto the instance directly by @{setChannel}. +---@class ScriptUpdateRecord +---@field namespace string Script namespace. +---@field data FeedScriptData Shallow copy of the raw script entry from the feed. +---@field config {c: {activeChannel?: string, lastChannel?: string, channels?: string[]}} +---@field moduleName string|false Namespace string for modules; false for automation scripts. +---@field logger Logger +---@field activeChannel? string Name of the currently active update channel. +---@field version? string Release version of the active channel (set by setChannel). +---@field files FeedFileData[] Platform-filtered file list for the active channel (set by setChannel). +---@field platforms? string[] Platforms supported by the active channel (set by setChannel). +class ScriptUpdateRecord + msgs = { + errors: { + noActiveChannel: "No active channel." + } + changelog: { + header: "Changelog for %s v%s (released %s):" + verTemplate: "v %s:" + msgTemplate: " • %s" + } + } + + -- Shared per-class metatable for the @data __index fallback; initialised lazily on first instantiation. + instanceMetaTable = nil + + --- Creates an update record for a single script entry in a feed. + ---@param namespace string + ---@param data FeedScriptData + ---@param config? {c: {activeChannel?: string}} + ---@param scriptType integer + ---@param autoChannel? boolean Select the default channel on construction (default true). + ---@param logger? Logger + new: (@namespace, data, @config = {c:{}}, scriptType, autoChannel = true, @logger = defaultLogger) => + @data = {k, v for k, v in pairs data} + @moduleName = scriptType == Common.ScriptType.Module and @namespace + + unless instanceMetaTable + meta = getmetatable @ + instanceMetaTable = {__index: (t, k) -> + v = meta[k] + return v if v != nil + d = rawget t, "data" + return d and d[k] + } + setmetatable @, instanceMetaTable + + @setChannel! if autoChannel + + + --- Returns all available channel names for this script and the default channel. + ---@return string[] channels + ---@return string? defaultChannel + getChannels: => + channels, default = {} + for name, channel in pairs @data.channels + channels[#channels+1] = name + if channel.default and not default + default = name + + return channels, default + + --- Selects the active update channel and exposes its fields on this instance. + ---@param channelName? string Channel to activate; defaults to config.c.activeChannel. + ---@return boolean success + ---@return string activeChannel + setChannel: (channelName = @config.c.activeChannel) => + with @config.c + .channels, default = @getChannels! + .lastChannel or= channelName or default + channelData = @data.channels[.lastChannel] + @activeChannel = .lastChannel + return false, @activeChannel unless channelData + @[k] = v for k, v in pairs channelData + + @files = @files and [file for file in *@files when not file.platform or file.platform == Common.platform] or {} + return true, @activeChannel + + --- Checks whether this script's active channel supports the current platform. + ---@return boolean supported + ---@return string platform + checkPlatform: => + @logger\assert @activeChannel, msgs.errors.noActiveChannel + return not @platforms or ({p,true for p in *@platforms})[Common.platform], Common.platform + + --- Formats changelog entries between the current version and a minimum version. + ---@param versionRecord any Unused; present for API compatibility. + ---@param minVer? number|string Oldest version to include (default 0, i.e. all). + ---@return string changelog Formatted multi-line string, or "" if nothing to show. + getChangelog: (versionRecord, minVer = 0) => + return "" unless "table" == type @changelog + maxVer = SemanticVersioning\toNumber @version + minVer = SemanticVersioning\toNumber minVer + + changelog = {} + for ver, entry in pairs @changelog + ver = SemanticVersioning\toNumber ver + verStr = SemanticVersioning\toString ver + if ver >= minVer and ver <= maxVer + changelog[#changelog+1] = {ver, verStr, entry} + + return "" if #changelog == 0 + table.sort changelog, (a,b) -> a[1]>b[1] + + msg = {msgs.changelog.header\format @name, SemanticVersioning\toString(@version), @released or ""} + for chg in *changelog + chg[3] = {chg[3]} if type(chg[3]) ~= "table" + if #chg[3] > 0 + msg[#msg+1] = @logger\format msgs.changelog.verTemplate, 1, chg[2] + msg[#msg+1] = @logger\format(msgs.changelog.msgTemplate, 1, entry) for entry in *chg[3] + + return table.concat msg, "\n" diff --git a/modules/DependencyControl/SemanticVersioning.moon b/modules/DependencyControl/SemanticVersioning.moon new file mode 100644 index 0000000..1d4d765 --- /dev/null +++ b/modules/DependencyControl/SemanticVersioning.moon @@ -0,0 +1,78 @@ +--- Semantic versioning utilities. +-- @class SemanticVersioning +class SemanticVersioning + msgs = { + toNumber: { + badString: "Can't parse version string '%s'. Make sure it conforms to semantic versioning standards." + badType: "Argument had the wrong type: expected a string or number, got a %s." + overflow: "Error: %s version must be an integer <= 255, got %s." + } + } + + semParts = {{"major", 16}, {"minor", 8}, {"patch", 0}} + + --- Converts a version number or string to a semantic version string. + ---@param version number|string + ---@param precision? SemverPrecision + ---@return string|nil versionString + ---@return string|nil err + @toString = (version, precision = "patch") => + if type(version) == "string" + version, err = @toNumber version + return nil, err unless version + + parts = {0, 0, 0} + for i, part in ipairs semParts + parts[i] = bit.rshift(version, part[2]) % 256 + break if precision == part[1] + + return "%d.%d.%d"\format unpack parts + + + --- Converts a semantic version string or number to an integer. + -- @param value string|number|nil The version as string (e.g. "1.2.3"), number, or nil. + -- @return number|false The integer version, or false on error. + -- @return string|nil Error message if conversion failed. + @toNumber = (value) => + return switch type value + when "number" then math.max value, 0 + when "nil" then 0 + when "string" + matches = {value\match "^(%d+)%.(%d+)%.(%d+)$"} + if #matches != 3 + return false, msgs.toNumber.badString\format value + + version = 0 + for i, part in ipairs semParts + value = tonumber matches[i] + if type(value) != "number" or value > 255 + return false, msgs.toNumber.overflow\format part[1], tostring value + + version += bit.lshift value, part[2] + version + + else false, msgs.toNumber.badType\format type value + + + --- Checks if version a is greater than or equal to version b, up to the given precision. + -- @param a number|string The first version (number or string). + -- @param b number|string The second version (number or string). + -- @param[opt="patch"] precision string The precision to use ("major", "minor", or "patch"). + -- @return boolean|nil True if a >= b, or nil on error. + -- @return number|nil The masked version of b, or error message if failed. + @check: (a, b, precision = "patch") => + if type(a) != "number" + a, err = @toNumber a + return nil, err unless a + + if type(b) != "number" + b, err = @toNumber b + return nil, err unless b + + mask = 0 + for part in *semParts + mask += 0xFF * 2^part[2] + break if precision == part[1] + + b = bit.band b, mask + return a >= b, b diff --git a/modules/DependencyControl/Stub.moon b/modules/DependencyControl/Stub.moon new file mode 100644 index 0000000..697a3dc --- /dev/null +++ b/modules/DependencyControl/Stub.moon @@ -0,0 +1,145 @@ + +Common = require "l0.DependencyControl.Common" +Logger = require "l0.DependencyControl.Logger" + +msgs = { + notCalled: "Expected stub to have been called, but it was never called." + wasCalled: "Expected stub not to have been called, but it was called %d time(s)." + wrongCallCount: "Expected stub to have been called %d time(s), but it was called %d time(s)." + notCalledWith: "No call matched the expected arguments (stub was called %d time(s)).\n Expected: %s" + noNthCall: "Expected at least %d call(s), but stub was only called %d time(s)." + wrongCall: "Call #%d arguments did not match.\n Expected: %s\n Actual: %s" + calledAfterRestore: "Stub for '%s' was called after being restored." + canary: { + notRestored: "Stub for '%s' was not restored before being garbage collected." + } +} + +_stubMatch = (call, expected) -> + for i = 1, expected.n + return false unless Common.equals call[i], expected[i] + return true + +--- A callable stub that records invocations and supports fluent configuration and assertions. +-- Can be used standalone or via UnitTest:stub for automatic lifecycle management. +-- @class Stub +class Stub + @logger = Logger fileBaseName: "DependencyControl.Stub" + + --- Creates a spy on a method, recording calls while still invoking the original method. + -- @param table table|string the table to spy into, or a module name (looked up in the module cache) + -- @param key string the field name to spy on + -- @param[opt] logger Logger the logger to use; when nil a default logger is used + -- @param[opt] unitTest UnitTest the unit test instance to report assertion failures + -- @return Stub + @spy = (table, key, logger, unitTest) => + s = @ table, key, logger, unitTest + return s\calls (...) -> s._originalMethod ... + + --- Creates a stub, optionally replacing a key in a table. + -- @param[opt] table table|string the table to stub into, or a module name (looked up in the module cache) + -- @param[opt] key string the field name to replace; when nil no table is modified + -- @param[opt] logger Logger the logger to use; when nil a default logger is used + -- @param[opt] unitTest UnitTest the unit test instance to report assertion failures to; when nil assertion failures throw errors + new: (table, key, logger, unitTest) => + @_calls = {} + @_replacement = -> + @unitTest = unitTest + restored = {false} + @_restored = restored + @logger = logger + + if type(table) == "string" + table = package.loaded[table] + + if table != nil and key != nil + @_targetTable = table + @_targetMethodKey = key + @_originalMethod = table[key] + table[key] = @ + + -- GC canary: warn if this stub is collected without restore() being called + keyRef, logger = key, @logger or @@logger + canary = newproxy true + (getmetatable canary).__gc = -> + unless restored[1] + pcall logger.warn, logger, msgs.canary.notRestored, keyRef + + meta = getmetatable @ + setmetatable @, { + __metatable: meta + __index: meta.__index + __call: meta.__call + __canary: canary + } + + __call: (...) => + @_fail msgs.calledAfterRestore, @_targetMethodKey if @_restored[1] + @_calls[#@_calls + 1] = table.pack ... + repl = @_replacement + return repl ... + + --- Sets the function to invoke when the stub is called. + -- @tparam function impl + -- @treturn Stub self + calls: (impl) => + @_replacement = impl + return @ + + --- Sets the stub to return fixed values on every call. + -- @treturn Stub self + returns: (...) => + vals = table.pack ... + @_replacement = -> unpack vals, 1, vals.n + return @ + + --- Restores the original value that was replaced by this stub. + restore: => + if @_targetTable != nil + @_targetTable[@_targetMethodKey] = @_originalMethod + @_restored[1] = true + + _fail: (msg, ...) => + if @unitTest + @unitTest\assert false, msg, ... + else + error string.format(msg, ...), 2 + + _dump: (val) => + return @unitTest.logger\dumpToString val if @unitTest + return tostring val + + assertCalled: => + @_fail msgs.notCalled unless #@_calls > 0 + + assertNotCalled: => + @_fail msgs.wasCalled, #@_calls unless #@_calls == 0 + + assertCalledTimes: (n) => + @_fail msgs.wrongCallCount, n, #@_calls unless #@_calls == n + + assertCalledOnce: => + @_fail msgs.wrongCallCount, 1, #@_calls unless #@_calls == 1 + + assertCalledOnceWith: (...) => + @_fail msgs.wrongCallCount, 1, #@_calls unless #@_calls == 1 + expected = table.pack ... + @_fail msgs.wrongCall, 1, @_dump(expected), @_dump(@_calls[1]) unless _stubMatch @_calls[1], expected + + assertCalledWith: (...) => + expected = table.pack ... + for call in *@_calls + return if _stubMatch call, expected + @_fail msgs.notCalledWith, #@_calls, @_dump expected + + assertLastCalledWith: (...) => + expected = table.pack ... + last = @_calls[#@_calls] + @_fail msgs.notCalled unless last != nil + @_fail msgs.wrongCall, #@_calls, @_dump(expected), @_dump last unless _stubMatch last, expected + + assertNthCalledWith: (n, ...) => + expected = table.pack ... + call = @_calls[n] + @_fail msgs.noNthCall, n, #@_calls unless call != nil + @_fail msgs.wrongCall, n, @_dump(expected), @_dump call unless _stubMatch call, expected diff --git a/modules/DependencyControl/TerribleMutex.moon b/modules/DependencyControl/TerribleMutex.moon new file mode 100644 index 0000000..25edc80 --- /dev/null +++ b/modules/DependencyControl/TerribleMutex.moon @@ -0,0 +1,87 @@ +-- Process-scoped mutex using native OS synchronization primitives. +-- +-- Preference rules: +-- Default: try BM.BadMutex first, fall back to FFI +-- DEPCTRL_PREFER_FFI_MUTEX=1: skip BM.BadMutex, always use FFI implementation +-- +-- Either way, if BM.BadMutex is not already in package.loaded after the attempt, +-- we register ourselves there so other modules get a working mutex. + +ffi = require "ffi" + +unless os.getenv("DEPCTRL_PREFER_FFI_MUTEX") == "1" + ok, native = pcall require, "BM.BadMutex" + return native if ok + +-- Build pure-FFI implementation. +-- The mutex name embeds the process ID so concurrent Aegisub / test-launcher +-- instances never share the same lock. +local tryLock, lock, unlock, canary + +if ffi.os == "Windows" + -- Named mutex (CreateMutexA) is thread-reentrant on Windows — the same thread can + -- acquire it again without blocking, unlike std::mutex. Use a binary semaphore + -- (initial=1, max=1) instead: WaitForSingleObject on a semaphore at count 0 + -- returns WAIT_TIMEOUT regardless of which thread holds it. + pcall ffi.cdef, "unsigned int GetCurrentProcessId(void);" + pcall ffi.cdef, "void *CreateSemaphoreA(void *attr, long initialCount, long maximumCount, const char *name);" + pcall ffi.cdef, "unsigned long WaitForSingleObject(void *hHandle, unsigned long dwMilliseconds);" + pcall ffi.cdef, "bool ReleaseSemaphore(void *hSemaphore, long lReleaseCount, long *lpPreviousCount);" + pcall ffi.cdef, "bool CloseHandle(void *hObject);" + + pid = ffi.C.GetCurrentProcessId! + name = ("DepCtrl_%d")\format pid + handle = ffi.C.CreateSemaphoreA nil, 1, 1, name + + WAIT_OBJECT_0 = 0 + INFINITE = 0xFFFFFFFF + + tryLock = -> ffi.C.WaitForSingleObject(handle, 0) == WAIT_OBJECT_0 + lock = -> ffi.C.WaitForSingleObject handle, INFINITE + unlock = -> ffi.C.ReleaseSemaphore handle, 1, nil + + canary = newproxy true + (getmetatable canary).__gc = -> ffi.C.CloseHandle handle + +else + -- O_CREAT: 64 (0100 octal) on Linux, 512 (0x200) on macOS + O_CREAT = ffi.os == "OSX" and 0x200 or 64 + + pcall ffi.cdef, [[ + int getpid(void); + void *sem_open(const char *name, int oflag, unsigned int mode, unsigned int value); + int sem_wait(void *sem); + int sem_trywait(void *sem); + int sem_post(void *sem); + int sem_close(void *sem); + int sem_unlink(const char *name); + ]] + + pid = ffi.C.getpid! + name = ("/depctrl_%d")\format pid + -- 0x1a4 = 0644 octal; initial value 1 makes this a binary semaphore + sem = ffi.C.sem_open name, O_CREAT, 0x1a4, 1 + + tryLock = -> ffi.C.sem_trywait(sem) == 0 + lock = -> ffi.C.sem_wait sem + unlock = -> ffi.C.sem_post sem + + -- sem_unlink removes the name; the semaphore lives until all sem_close calls complete, + -- so other states' handles remain valid after unlink. Repeated unlink calls fail silently. + canary = newproxy true + (getmetatable canary).__gc = -> + ffi.C.sem_close sem + ffi.C.sem_unlink name + + +mutex = { + :tryLock, :lock, :unlock + __canary: canary -- keeps canary alive for this module's lifetime + -- Satisfies DepCtrl's version check for BM.BadMutex without a full record. + version: "0.1.3" +} + +-- Register as BM.BadMutex so other scripts requiring it get a working mutex. +package.loaded["BM.BadMutex"] = mutex + +return mutex diff --git a/modules/DependencyControl/Tests.moon b/modules/DependencyControl/Tests.moon new file mode 100644 index 0000000..7ea04f4 --- /dev/null +++ b/modules/DependencyControl/Tests.moon @@ -0,0 +1,2108 @@ +DependencyControl = require "l0.DependencyControl" + +DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> + lfs = require "lfs" + ffi = require "ffi" + Logger = require "l0.DependencyControl.Logger" + Common = require "l0.DependencyControl.Common" + Enum = require "l0.DependencyControl.Enum" + FileOps = require "l0.DependencyControl.FileOps" + SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" + Lock = require "l0.DependencyControl.Lock" + ConfigHandler = require "l0.DependencyControl.ConfigHandler" + ConfigView = require "l0.DependencyControl.ConfigView" + ModuleLoader = require "l0.DependencyControl.ModuleLoader" + Record = require "l0.DependencyControl.Record" + UpdateFeed = require "l0.DependencyControl.UpdateFeed" + ScriptUpdateRecord = require "l0.DependencyControl.ScriptUpdateRecord" + Timer = require "l0.DependencyControl.Timer" + TerribleMutex = require "l0.DependencyControl.TerribleMutex" + Downloader = require "l0.DependencyControl.Downloader" + Crypto = require "l0.DependencyControl.Crypto" + ModuleProvider = require "l0.DependencyControl.ModuleProvider" + + TERRIBLE_MUTEX_MODULE_NAME = "l0.DependencyControl.TerribleMutex" + TIMER_MODULE_NAME = "l0.DependencyControl.Timer" + FILEOPS_MODULE_NAME = "l0.DependencyControl.FileOps" + JSON_MODULE_NAME = "json" + + isWindows = ffi.os == "Windows" + pathSep = isWindows and "\\" or "/" + basePath = aegisub.decode_path "?temp/l0.#{DependencyControl.__name}.#{DependencyControl.UnitTestSuite.__name}_#{'%04X'\format math.random 0, 16^4-1}" + + -- Fake transfer driver for Downloader.multiplex: each download completes after + -- `steps` step() calls (1 byte each), recording the order step() is called so + -- tests can assert round-robin fairness without any real network I/O. + makeFakeDriver = (steps, order) -> + { + start: (dl) -> + dl.totalBytes = steps + dl.bytesReceived = 0 + true + step: (dl) -> + order[#order + 1] = dl.id + dl.bytesReceived += 1 + return "done" if dl.bytesReceived >= steps + "more" + finish: (dl) -> nil + } + + -- builds a downloader whose runner drives multiplex with the given fake driver + fakeManager = (driver) -> + Downloader (mgr) -> Downloader.multiplex mgr, driver + + Status = Downloader.Download.Status + + -- generates a process-unique module-alias name (the ModuleProvider registry is global) + uniqueName = (prefix) -> "#{prefix}_#{'%08X'\format math.random 0, 16^8-1}" + + { + Timer: { + _description: "Tests for the FFI-based Timer: monotonic timing and millisecond sleep." + + -- timeElapsed + + timeElapsed_nonNegative: (ut) -> + t = Timer! + ut\assertGreaterThanOrEquals t\timeElapsed!, 0 + + timeElapsed_monotonic: (ut) -> + t = Timer! + a = t\timeElapsed! + b = t\timeElapsed! + ut\assertGreaterThanOrEquals b, a + + timeElapsed_advancesAfterSleep: (ut) -> + t = Timer! + Timer.sleep 20 -- 20 ms + -- Require at least 10 ms to pass; allows 50% margin for CI jitter. + ut\assertGreaterThan t\timeElapsed!, 0.010 + + -- sleep + + sleep_isCallable: (ut) -> + -- Smoke test: sleep(0) must not error and must return. + Timer.sleep 0 + ut\assertTrue true + + sleep_onClass: (ut) -> + -- sleep is a static method accessible directly on the class. + ut\assertFunction Timer.sleep + + sleep_onInstance: (ut) -> + -- sleep is also accessible through an instance (class method inheritance). + t = Timer! + ut\assertFunction t.sleep + + _order: { + "timeElapsed_nonNegative", "timeElapsed_monotonic", + "timeElapsed_advancesAfterSleep", + "sleep_isCallable", "sleep_onClass", "sleep_onInstance" + } + } + + TerribleMutex: { + _description: "Tests for TerribleMutex: FFI-based process-scoped mutex that fills in for BM.BadMutex." + + -- API surface + + api_hasTryLock: (ut) -> + ut\assertFunction TerribleMutex.tryLock + + api_hasLock: (ut) -> + ut\assertFunction TerribleMutex.lock + + api_hasUnlock: (ut) -> + ut\assertFunction TerribleMutex.unlock + + -- tryLock / unlock round-trip + + tryLock_acquires: (ut) -> + result = TerribleMutex.tryLock! + ut\assertTrue result + TerribleMutex.unlock! -- release so subsequent tests start clean + + tryLock_failsWhenHeld: (ut) -> + ut\assertTrue TerribleMutex.tryLock! -- acquire + result = TerribleMutex.tryLock! -- second attempt must fail + TerribleMutex.unlock! + ut\assertFalse result + + unlock_releasesLock: (ut) -> + ut\assertTrue TerribleMutex.tryLock! + TerribleMutex.unlock! + result = TerribleMutex.tryLock! -- must succeed again after release + TerribleMutex.unlock! + ut\assertTrue result + + -- BM.BadMutex alias + + registered_asBadMutex: (ut) -> + -- TerribleMutex registers itself (or native BM.BadMutex) under this name + ut\assertNotNil package.loaded["BM.BadMutex"] + + _order: { + "api_hasTryLock", "api_hasLock", "api_hasUnlock", + "tryLock_acquires", "tryLock_failsWhenHeld", "unlock_releasesLock", + "registered_asBadMutex" + } + } + + Crypto: { + _description: "Tests for the pure-Lua Crypto utilities (SHA-1) against known vectors." + + sha1_abc: (ut) -> + ut\assertEquals Crypto.sha1("abc"), "a9993e364706816aba3e25717850c26c9cd0d89d" + + sha1_empty: (ut) -> + ut\assertEquals Crypto.sha1(""), "da39a3ee5e6b4b0d3255bfef95601890afd80709" + + -- exercises multi-block padding (>55 bytes) + sha1_quickBrownFox: (ut) -> + ut\assertEquals Crypto.sha1("The quick brown fox jumps over the lazy dog"), + "2fd4e1c67a2d28fced849ee1bb76e7391b93eb12" + + -- binary payloads (embedded NUL and high bytes) hash without error + sha1_binaryData: (ut) -> + digest = Crypto.sha1 "\0\1\2\254\255" + ut\assertMatches digest, "^%x+$" + ut\assertEquals #digest, 40 + + sha1_rejectsNonString: (ut) -> + result, err = Crypto.sha1 42 + ut\assertNil result + ut\assertString err + + -- whichever backend is active (native or lua) must match the reference impl + sha1_backendMatchesReference: (ut) -> + for input in *{"", "abc", "The quick brown fox jumps over the lazy dog", "\0\1\2\254\255"} + ut\assertEquals Crypto.sha1(input), Crypto._sha1Lua(input) + + _order: { + "sha1_abc", "sha1_empty", "sha1_quickBrownFox", + "sha1_binaryData", "sha1_rejectsNonString", "sha1_backendMatchesReference" + } + } + + ModuleProvider: { + _description: "Tests for ModuleProvider: alias registration and searcher-based resolution. (Unique names per run; the registry is process-global.)" + + register_andGetProvider: (ut) -> + name = uniqueName "alias" + ut\assertTrue ModuleProvider.register name, "some.provider" + ut\assertEquals ModuleProvider.getProvider(name), "some.provider" + + register_firstWins: (ut) -> + name = uniqueName "alias" + ut\assertTrue ModuleProvider.register name, "first.provider" + ut\assertFalse ModuleProvider.register name, "second.provider" -- already registered + ut\assertEquals ModuleProvider.getProvider(name), "first.provider" + + registerRecord_normalizesAliases: (ut) -> + stringAlias, tableAlias = uniqueName("string"), uniqueName "table" + ModuleProvider.registerRecord {moduleName: "prov.A", provides: {stringAlias}} + ModuleProvider.registerRecord {moduleName: "prov.B", provides: {{name: tableAlias}}} + ut\assertEquals ModuleProvider.getProvider(stringAlias), "prov.A" + ut\assertEquals ModuleProvider.getProvider(tableAlias), "prov.B" + + -- end to end: a require of a registered alias resolves to the provider module + searcher_resolvesAliasToProvider: (ut) -> + ModuleProvider.install! -- idempotent; already installed during load + name = uniqueName "aliasToSemver" + ModuleProvider.register name, "l0.DependencyControl.SemanticVersioning" + resolved = require name + package.loaded[name] = nil -- don't leak the alias into the module cache + ut\assertIs resolved, SemanticVersioning + + _order: { + "register_andGetProvider", "register_firstWins", + "registerRecord_normalizesAliases", "searcher_resolvesAliasToProvider" + } + } + + Downloader: { + _description: "Tests for the Downloader engine: round-robin scheduling and per-download callbacks (via a fake driver). (Offline — no network.)" + + -- round-robin scheduling: the scheduler must step every active transfer once + -- per pass, so two downloads interleave rather than running one to completion first + + roundRobin_interleaves: (ut) -> + order = {} + dm = fakeManager makeFakeDriver 3, order + dm\addDownload "http://x/1", "#{basePath}_rr1" + dm\addDownload "http://x/2", "#{basePath}_rr2" + dm\await! + -- 2 downloads × 3 steps; each pass touches both before re-stepping either + ut\assertEquals #order, 6 + ut\assertNotEquals order[1], order[2] -- first pass touched both + ut\assertNotEquals order[3], order[4] -- second pass too + ut\assertEquals dl.status, Status.Finished for dl in *dm.downloads + + -- the user-described scenario: start two slow downloads, detect (via the + -- progress callback) that both are in flight simultaneously, then abort early + + roundRobin_detectsConcurrencyThenCancels: (ut) -> + order = {} + dm = fakeManager makeFakeDriver 1000, order -- "slow": many steps to finish + dm\addDownload "http://x/1", "#{basePath}_c1" + dm\addDownload "http://x/2", "#{basePath}_c2" + + maxConcurrent = 0 + dm\on Downloader.Event.Progress, (downloader, percent) -> + inFlight = 0 + for dl in *dm.downloads + inFlight += 1 if dl.status == Status.Active and (dl.bytesReceived or 0) > 0 + maxConcurrent = math.max maxConcurrent, inFlight + dm\cancel! if maxConcurrent >= 2 -- proven concurrent → abort + dm\await! + + ut\assertGreaterThanOrEquals maxConcurrent, 2 + -- aborted after the first pass: neither 1000-step download finished + ut\assertEquals dl.status, Status.Cancelled for dl in *dm.downloads + + -- Finish event listeners fire on completion and may mark the download failed + -- (the mechanism SHA-1 verification rides on) + + finishEvent_canMarkFailed: (ut) -> + dm = fakeManager makeFakeDriver 1, {} + dl = dm\addDownload "http://x/1", "#{basePath}_fin" + fired = false + dl\on Downloader.Download.Event.Finish, (d) -> + fired = true + d\markFailed "verification failed" + dm\await! + ut\assertTrue fired + ut\assertEquals dl.error, "verification failed" + ut\assertEquals dl.status, Status.Failed + + -- on/off: a removed listener no longer fires + + on_off: (ut) -> + dl = Downloader.Download "http://x/1", "#{basePath}_o", 1 + count = 0 + cb = (d) -> count += 1 + dl\on Downloader.Download.Event.Progress, cb + dl\_notifyProgress! + dl\off Downloader.Download.Event.Progress, cb + dl\_notifyProgress! + ut\assertEquals count, 1 + + on_rejectsUnknownEvent: (ut) -> + dl = Downloader.Download "http://x/1", "#{basePath}_u", 1 + ut\assertError -> dl\on "notAnEvent", -> + + -- addDownload sha1: a matching hash leaves no error; a mismatch records one + + addDownload_sha1Verifies: (ut) -> + path = "#{basePath}_sha1ok.txt" + handle = io.open path, "wb" + handle\write "abc" + handle\close! + dm = fakeManager makeFakeDriver 1, {} + dl = dm\addDownload "http://x/1", path, "a9993e364706816aba3e25717850c26c9cd0d89d" + dm\await! + os.remove path + ut\assertNil dl.error + ut\assertEquals dl.status, Status.Finished + + addDownload_sha1Mismatch: (ut) -> + path = "#{basePath}_sha1bad.txt" + handle = io.open path, "wb" + handle\write "abc" + handle\close! + dm = fakeManager makeFakeDriver 1, {} + dl = dm\addDownload "http://x/1", path, ("0")\rep 40 + dm\await! + os.remove path + ut\assertString dl.error + ut\assertEquals dl.status, Status.Failed + + -- Downloader-level events: Progress fires during, Finished fires after await + + downloaderEvents: (ut) -> + dm = fakeManager makeFakeDriver 2, {} + dm\addDownload "http://x/1", "#{basePath}_de" + progressCount, finished = 0, false + dm\on Downloader.Event.Progress, (d, percent) -> progressCount += 1 + dm\on Downloader.Event.Finished, (d) -> finished = true + dm\await! + ut\assertGreaterThan progressCount, 0 + ut\assertTrue finished + + -- a failed start marks the download Failed with the start error + + runner_recordsStartFailure: (ut) -> + failingDriver = { + start: (dl) -> false, "boom" + step: (dl) -> "done" + finish: (dl) -> nil + } + dm = Downloader (mgr) -> Downloader.multiplex mgr, failingDriver + dm\addDownload "http://x/1", "#{basePath}_f1" + dm\await! + ut\assertEquals dm.downloads[1].error, "boom" + ut\assertEquals dm.downloads[1].status, Status.Failed + + -- a single download can be cancelled mid-flight without affecting the others + + individualCancel: (ut) -> + order = {} + dm = fakeManager makeFakeDriver 3, order + dl1 = dm\addDownload "http://x/1", "#{basePath}_ic1" + dl2 = dm\addDownload "http://x/2", "#{basePath}_ic2" + dm\on Downloader.Event.Progress, -> dl1\cancel! -- cancel dl1 once it's underway + dm\await! + ut\assertEquals dl1.status, Status.Cancelled + ut\assertEquals dl2.status, Status.Finished + + -- addDownload queueing and validation + + addDownload_queues: (ut) -> + dm = Downloader! + dl = dm\addDownload "https://example.com/x", "#{basePath}_dl.txt" + ut\assertEquals dl.url, "https://example.com/x" + ut\assertEquals #dm.downloads, 1 + + addDownload_badArgs: (ut) -> + dl, err = Downloader!\addDownload nil, nil + ut\assertNil dl + ut\assertString err + + -- clear empties the arrays in place (external references stay valid) + + clear_emptiesInPlace: (ut) -> + dm = Downloader! + downloadsRef = dm.downloads + dm\addDownload "http://x/1", "#{basePath}_cl" + dm\clear! + ut\assertEquals #dm.downloads, 0 + ut\assertIs dm.downloads, downloadsRef -- same table, emptied in place + + _order: { + "roundRobin_interleaves", "roundRobin_detectsConcurrencyThenCancels", + "finishEvent_canMarkFailed", "on_off", "on_rejectsUnknownEvent", + "addDownload_sha1Verifies", "addDownload_sha1Mismatch", + "downloaderEvents", + "runner_recordsStartFailure", "individualCancel", + "addDownload_queues", "addDownload_badArgs", + "clear_emptiesInPlace" + } + } + + Common: { + _description: "Tests for the Common base class providing shared utilities and enums across DependencyControl components." + + capitalizeTerms: (ut) -> + ut\assertEquals DepCtrl.terms.capitalize("hello world"), "Hello world" + + -- validateNamespace: pure computation, no stubs needed + + validateNamespace_valid: (ut) -> + result, err = Common.validateNamespace "l0.DependencyControl" + ut\assertTrue result + ut\assertNil err + + validateNamespace_multiPart: (ut) -> + result, err = Common.validateNamespace "a.b.c" + ut\assertTrue result + ut\assertNil err + + validateNamespace_noDot: (ut) -> + result, err = Common.validateNamespace "no-dot" + ut\assertFalse result + ut\assertString err + + validateNamespace_leadingDot: (ut) -> + result, err = Common.validateNamespace ".foo.bar" + ut\assertFalse result + ut\assertString err + + validateNamespace_trailingDot: (ut) -> + result, err = Common.validateNamespace "foo.bar." + ut\assertFalse result + ut\assertString err + + validateNamespace_invalidChars: (ut) -> + result, err = Common.validateNamespace "foo bar.baz" + ut\assertFalse result + ut\assertString err + + _order: { + "capitalizeTerms", + "validateNamespace_valid", "validateNamespace_multiPart", + "validateNamespace_noDot", "validateNamespace_leadingDot", + "validateNamespace_trailingDot", "validateNamespace_invalidChars" + } + } + + FileOps: { + _description: "Tests for FileOps path validation and filesystem utilities." + + -- validateFullPath: pure computation, no stubs needed + + validateFullPath_nonString: (ut) -> + result, err = FileOps.validateFullPath 42 + ut\assertNil result + ut\assertString err + + validateFullPath_parentDir: (ut) -> + result = FileOps.validateFullPath {basePath, "..", "escape.txt"} + ut\assertFalse result + + validateFullPath_tooLong: (ut) -> + result = FileOps.validateFullPath {basePath, "#{string.rep 'a', 300}.txt"} + ut\assertFalse result + + validateFullPath_invalidChars: (ut) -> + return unless isWindows + result = FileOps.validateFullPath {basePath, "with.txt"} + ut\assertFalse result + + validateFullPath_reservedNames: (ut) -> + return unless isWindows + result = FileOps.validateFullPath {basePath, "CON", "file.txt"} + ut\assertFalse result + + validateFullPath_valid: (ut) -> + path, dev, dir, file = FileOps.validateFullPath {basePath, "file.txt"} + ut\assertString path + ut\assertString dev + ut\assertEquals file, "file.txt" + + validateFullPath_noExt_rejected: (ut) -> + result = FileOps.validateFullPath {basePath, "no-ext"}, true + ut\assertFalse result + + validateFullPath_withExt_accepted: (ut) -> + result = FileOps.validateFullPath {basePath, "file.txt"}, true + ut\assertString result + + validateFullPath_homeDirExpansion: (ut) -> + return if isWindows + home = os.getenv "HOME" + return unless home + result = FileOps.validateFullPath {"~", "subdir", "file.txt"} + ut\assertString result + ut\assertContains result, home + + -- getNamespacedPath: pure computation, no stubs needed + + getNamespacedPath_nested: (ut) -> + path, err = FileOps.getNamespacedPath basePath, "l0.DependencyControl.Test", ".lua" + ut\assertNil err + ut\assertString path + ut\assertContains path, FileOps.joinPath "l0", "DependencyControl", "Test.lua" + + getNamespacedPath_flat: (ut) -> + path, err = FileOps.getNamespacedPath basePath, "l0.DependencyControl", ".lua", false + ut\assertNil err + ut\assertString path + ut\assertContains path, "l0.DependencyControl.lua" + + getNamespacedPath_badNamespace: (ut) -> + path, err = FileOps.getNamespacedPath basePath, "not-a-namespace", ".lua" + ut\assertNil path + ut\assertString err + + getNamespacedPath_badBasePath: (ut) -> + path, err = FileOps.getNamespacedPath {"relative", "path"}, "l0.DependencyControl", ".lua" + ut\assertNil path + ut\assertString err + + -- attributes: stubs lfs.attributes + -- lfs.attributes(path, key) returns (value) on success, (nil) when not found, + -- or (nil, errmsg) on error. FileOps.attributes maps these to value/false/nil. + + attributes_file: (ut) -> + attrStub = (ut\stub lfs, "attributes")\calls (path, key) -> "file" + mode, fullPath = FileOps.attributes {basePath, "file.txt"}, "mode" + ut\assertEquals mode, "file" + ut\assertString fullPath + attrStub\assertCalledOnceWith FileOps.joinPath(basePath, "file.txt"), "mode" + + attributes_notFound: (ut) -> + attrStub = (ut\stub lfs, "attributes")\calls (path, key) -> nil + mode, fullPath = FileOps.attributes {basePath, "missing.txt"}, "mode" + ut\assertFalse mode + ut\assertString fullPath + attrStub\assertCalledOnceWith FileOps.joinPath(basePath, "missing.txt"), "mode" + + -- joinPath: pure computation, no stubs needed + + joinPath_segmentsArray: (ut) -> + result = FileOps.joinPath {"path", "to", "file.txt"} + ut\assertEquals result, "path#{pathSep}to#{pathSep}file.txt" + + joinPath_segmentsVarargs: (ut) -> + result = FileOps.joinPath "path", "to", "file.txt" + ut\assertEquals result, "path#{pathSep}to#{pathSep}file.txt" + + joinPath_segmentsMixed: (ut) -> + result = FileOps.joinPath {"path", "to"}, "file.txt" + ut\assertEquals result, "path#{pathSep}to#{pathSep}file.txt" + + -- mkdir: stubs lfs.attributes + lfs.mkdir + + mkdir_new: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> nil + mkdirStub = (ut\stub lfs, "mkdir")\calls (path) -> true + result, path = FileOps.mkdir {basePath, "newdir"} + ut\assertTrue result + ut\assertString path + mkdirStub\assertCalledOnce! + + mkdir_exists: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> "directory" + result, dir = FileOps.mkdir {basePath, "existing"} + ut\assertFalse result + ut\assertString dir + + -- readFile: stubs lfs.attributes + io.open + + readFile_success: (ut) -> + filePath = FileOps.joinPath basePath, "file.txt" + content = "hello, DependencyControl" + mockHandle = { + read: (handle, fmt) -> content + close: (handle) -> + } + (ut\stub lfs, "attributes")\calls (path, key) -> "file" + openStub = (ut\stub io, "open")\calls (path, mode) -> mockHandle + data, err = FileOps.readFile filePath + ut\assertEquals data, content + ut\assertNil err + openStub\assertCalledOnceWith filePath, "rb" + + readFile_isDirectory: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> "directory" + data, err = FileOps.readFile {basePath, "dir"} + ut\assertNil data + ut\assertString err + + -- getHash / verifyHash: stub readFile so the hash is computed over known content + + getHash_sha1: (ut) -> + (ut\stub FILEOPS_MODULE_NAME, "readFile")\returns "abc" + ut\assertEquals FileOps.getHash("/path/file", "sha1"), + "a9993e364706816aba3e25717850c26c9cd0d89d" + + getHash_defaultsToSha1: (ut) -> + (ut\stub FILEOPS_MODULE_NAME, "readFile")\returns "abc" + ut\assertEquals FileOps.getHash("/path/file"), + "a9993e364706816aba3e25717850c26c9cd0d89d" + + getHash_unsupportedType: (ut) -> + hash, err = FileOps.getHash "/path/file", "md5" + ut\assertNil hash + ut\assertString err + + verifyHash_match: (ut) -> + (ut\stub FILEOPS_MODULE_NAME, "readFile")\returns "abc" + ut\assertTrue FileOps.verifyHash "/path/file", "A9993E364706816ABA3E25717850C26C9CD0D89D", "sha1" + + verifyHash_mismatch: (ut) -> + (ut\stub FILEOPS_MODULE_NAME, "readFile")\returns "abc" + ok, err = FileOps.verifyHash "/path/file", ("0")\rep(40), "sha1" + ut\assertFalse ok + ut\assertString err + + verifyHash_badArg: (ut) -> + ok, err = FileOps.verifyHash "/path/file", nil + ut\assertNil ok + ut\assertString err + + -- copy: stubs lfs.attributes + io.open + + copy_success: (ut) -> + srcPath = FileOps.joinPath basePath, "src.txt" + dstPath = FileOps.joinPath basePath, "dst.txt" + mockIn = { + read: (handle, fmt) -> "content" + close: (handle) -> + } + mockOut = { + write: (handle, data) -> true + close: (handle) -> + } + (ut\stub lfs, "attributes")\calls (path, key) -> + if path == srcPath then "file" else nil + ioStub = (ut\stub io, "open")\calls (path, mode) -> + if mode == "rb" then mockIn else mockOut + result, err = FileOps.copy srcPath, dstPath + ioStub\assertCalledTimes 2 + ioStub\assertNthCalledWith 1, srcPath, "rb" + ioStub\assertNthCalledWith 2, dstPath, "wb" + ut\assertTrue result + ut\assertNil err + + copy_targetExists: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> "file" + result, err = FileOps.copy {basePath, "src.txt"}, {basePath, "dst.txt"} + ut\assertFalse result + ut\assertString err + + -- move: stubs lfs.attributes + os.remove + os.rename + + move_overwrite: (ut) -> + srcPath = FileOps.joinPath basePath, "src.txt" + dstPath = FileOps.joinPath basePath, "dst.txt" + (ut\stub lfs, "attributes")\calls (path, key) -> "file" + removeStub = (ut\stub os, "remove")\returns true + renameStub = (ut\stub os, "rename")\returns true + result, err = FileOps.move srcPath, dstPath, true + ut\assertTrue result + ut\assertNil err + removeStub\assertCalledOnceWith dstPath + renameStub\assertCalledOnceWith srcPath, dstPath + + -- remove: stubs lfs.attributes + os.remove + + remove_success: (ut) -> + filePath = FileOps.joinPath basePath, "file.txt" + (ut\stub lfs, "attributes")\calls (path, key) -> "file" + removeStub = (ut\stub os, "remove")\returns true + result, details = FileOps.remove filePath + ut\assertTrue result + removeStub\assertCalledOnceWith filePath + + remove_notFound: (ut) -> + (ut\stub lfs, "attributes")\calls (path, key) -> nil + result, details = FileOps.remove FileOps.joinPath basePath, "missing.txt" + ut\assertTrue result + ut\assertTable details + + _order: { + "validateFullPath_nonString", "validateFullPath_parentDir", "validateFullPath_tooLong", + "validateFullPath_invalidChars", "validateFullPath_reservedNames", + "validateFullPath_valid", "validateFullPath_noExt_rejected", "validateFullPath_withExt_accepted", + "validateFullPath_homeDirExpansion", + "getNamespacedPath_nested", "getNamespacedPath_flat", + "getNamespacedPath_badNamespace", "getNamespacedPath_badBasePath", + "attributes_file", "attributes_notFound", + "mkdir_new", "mkdir_exists", + "readFile_success", "readFile_isDirectory", + "getHash_sha1", "getHash_defaultsToSha1", "getHash_unsupportedType", + "verifyHash_match", "verifyHash_mismatch", "verifyHash_badArg", + "copy_success", "copy_targetExists", + "move_overwrite", + "remove_success", "remove_notFound" + } + } + + Logger: { + _description: "Tests for the Logger class covering message formatting, dump serialization, and log dispatch." + + -- format: pure computation, no stubs needed + + format_string: (ut) -> + logger = Logger toFile: false, toWindow: false + result = logger\format "hello world", 0 + ut\assertEquals result, "hello world" + + format_printf: (ut) -> + logger = Logger toFile: false, toWindow: false + result = logger\format "value: %d", 0, 42 + ut\assertEquals result, "value: 42" + + format_table: (ut) -> + logger = Logger toFile: false, toWindow: false + result = logger\format {"line1", "line2"}, 0 + ut\assertEquals result, "line1\nline2" + + format_indent: (ut) -> + logger = Logger toFile: false, toWindow: false + result = logger\format "line1\nline2", 1 + ut\assertContains result, "— line2" + + -- dumpToString: pure computation, no stubs needed + + dumpToString_scalar: (ut) -> + logger = Logger toFile: false, toWindow: false + ut\assertEquals logger\dumpToString("hello"), "hello" + ut\assertEquals logger\dumpToString(42), "42" + ut\assertEquals logger\dumpToString(true), "true" + + dumpToString_flatTable: (ut) -> + logger = Logger toFile: false, toWindow: false + result = logger\dumpToString {key: "val"} + ut\assertContains result, "key:" + ut\assertContains result, "val" + + dumpToString_ignoreKey: (ut) -> + logger = Logger toFile: false, toWindow: false + result = logger\dumpToString {keep: "yes", skip: "no"}, "skip" + ut\assertContains result, "keep:" + ut\assertNil result\find "skip:", 1, true + + dumpToString_maxDepth: (ut) -> + logger = Logger toFile: false, toWindow: false + nested = {inner: {deep: "value"}} + result = logger\dumpToString nested, nil, 0 + ut\assertContains result, "<...>" + + dumpToString_circular: (ut) -> + logger = Logger toFile: false, toWindow: false + t = {} + t.self = t + result = logger\dumpToString t + ut\assertContains result, "self: @1" + + -- log/dispatch: stubs aegisub.log + + log_dispatches: (ut) -> + logger = Logger toFile: false, toWindow: true + logStub = ut\stub aegisub, "log" + result = logger\log 2, "hello" + ut\assertTrue result + logStub\assertCalledOnce! + + log_emptyMsg: (ut) -> + logger = Logger toFile: false, toWindow: true + logStub = ut\stub aegisub, "log" + result = logger\log 2, "" + ut\assertFalse result + logStub\assertNotCalled! + + log_nonNumberLevel: (ut) -> + logger = Logger toFile: false, toWindow: true + logStub = ut\stub aegisub, "log" + result = logger\log "hello" + ut\assertTrue result + logStub\assertCalledOnce! + + -- assert/assertNotNil: success path returns values, failure path throws + + assert_truthy: (ut) -> + logger = Logger toFile: false, toWindow: false + result, extra = logger\assert true, "should not log" + ut\assertTrue result + ut\assertEquals extra, "should not log" + + assert_falsy: (ut) -> + logger = Logger toFile: false, toWindow: false + ok, err = pcall -> logger\assert false, "boom" + ut\assertFalse ok + ut\assertString err + + assertNotNil_value: (ut) -> + logger = Logger toFile: false, toWindow: false + result = logger\assertNotNil 0, "should not log" + ut\assertEquals result, 0 + + assertNotNil_nil: (ut) -> + logger = Logger toFile: false, toWindow: false + ok, err = pcall -> logger\assertNotNil nil, "boom" + ut\assertFalse ok + ut\assertString err + + _order: { + "format_string", "format_printf", "format_table", "format_indent", + "dumpToString_scalar", "dumpToString_flatTable", "dumpToString_ignoreKey", + "dumpToString_maxDepth", "dumpToString_circular", + "log_dispatches", "log_emptyMsg", "log_nonNumberLevel", + "assert_truthy", "assert_falsy", + "assertNotNil_value", "assertNotNil_nil" + } + } + + Enum: { + _description: "Tests for the Enum class providing immutable enumeration types with reverse lookup." + + -- construction + + new_table: (ut) -> + e = Enum "MyEnum", {Foo: 1, Bar: 2} + ut\assertEquals e.Foo, 1 + ut\assertEquals e.Bar, 2 + + new_list: (ut) -> + e = Enum "MyEnum", {"Foo", "Bar"} + found = e\test "Foo" + ut\assertTrue found + + new_badName: (ut) -> + ok, err = pcall -> Enum 42, {Foo: 1} + ut\assertFalse ok + ut\assertString err + + new_reservedKey: (ut) -> + ok, err = pcall -> Enum "MyEnum", {keys: 1} + ut\assertFalse ok + ut\assertString err + + new_duplicateValue: (ut) -> + ok, err = pcall -> Enum "MyEnum", {Foo: 1, Bar: 1} + ut\assertFalse ok + ut\assertString err + + -- test + + test_found: (ut) -> + e = Enum "MyEnum", {Foo: 1, Bar: 2} + found, val = e\test "Foo" + ut\assertTrue found + ut\assertEquals val, 1 + + test_notFound: (ut) -> + e = Enum "MyEnum", {Foo: 1} + found, val = e\test "Baz" + ut\assertFalse found + ut\assertNil val + + -- describe + + describe_single: (ut) -> + e = Enum "MyEnum", {Foo: 1, Bar: 2} + result = e\describe 1 + ut\assertEquals result, "Foo" + + describe_list: (ut) -> + e = Enum "MyEnum", {Foo: 1, Bar: 2} + result = e\describe {1, 2} + ut\assertTable result + ut\assertEquals #result, 2 + + describe_join: (ut) -> + e = Enum "MyEnum", {Foo: 1, Bar: 2} + result = e\describe {1, 2}, true + ut\assertString result + ut\assertContains result, "Foo" + ut\assertContains result, "Bar" + + describe_unknown: (ut) -> + e = Enum "MyEnum", {Foo: 1} + result, err = e\describe 99 + ut\assertNil result + ut\assertContains err, "MyEnum" + ut\assertContains err, "99" + + -- validate + + validate_valid: (ut) -> + e = Enum "MyEnum", {Foo: 1, Bar: 2} + result, err = e\validate 1 + ut\assertTrue result + ut\assertNil err + + validate_invalid: (ut) -> + e = Enum "MyEnum", {Foo: 1} + result, err = e\validate 99 + ut\assertNil result + ut\assertString err + + validate_withArgName: (ut) -> + e = Enum "MyEnum", {Foo: 1} + result, err = e\validate 99, "myArg" + ut\assertNil result + ut\assertContains err, "myArg" + + -- immutability + + immutable_read: (ut) -> + e = Enum "MyEnum", {Foo: 1} + ok, err = pcall -> e.Bar + ut\assertFalse ok + ut\assertString err + + immutable_write: (ut) -> + e = Enum "MyEnum", {Foo: 1} + ok, err = pcall -> e.Foo = 99 + ut\assertFalse ok + ut\assertString err + + _order: { + "new_table", "new_list", "new_badName", "new_reservedKey", "new_duplicateValue", + "test_found", "test_notFound", + "describe_single", "describe_list", "describe_join", "describe_unknown", + "validate_valid", "validate_invalid", "validate_withArgName", + "immutable_read", "immutable_write" + } + } + + SemanticVersioning: { + _description: "Tests for SemanticVersioning covering toNumber, toString, and check." + + -- toNumber + + toNumber_string: (ut) -> + result, err = SemanticVersioning\toNumber "1.2.3" + ut\assertEquals result, 66051 + ut\assertNil err + + toNumber_zero: (ut) -> + result, err = SemanticVersioning\toNumber "0.0.0" + ut\assertEquals result, 0 + ut\assertNil err + + toNumber_number: (ut) -> + result = SemanticVersioning\toNumber 66051 + ut\assertEquals result, 66051 + + toNumber_nil: (ut) -> + result = SemanticVersioning\toNumber nil + ut\assertEquals result, 0 + + toNumber_badString: (ut) -> + result, err = SemanticVersioning\toNumber "1.2" + ut\assertFalse result + ut\assertString err + + toNumber_overflow: (ut) -> + result, err = SemanticVersioning\toNumber "1.256.0" + ut\assertFalse result + ut\assertString err + + toNumber_badType: (ut) -> + result, err = SemanticVersioning\toNumber {} + ut\assertFalse result + ut\assertString err + + -- toString + + toString_fromNumber: (ut) -> + result, err = SemanticVersioning\toString 66051 + ut\assertEquals result, "1.2.3" + ut\assertNil err + + toString_roundtrip: (ut) -> + result, err = SemanticVersioning\toString "1.2.3" + ut\assertEquals result, "1.2.3" + ut\assertNil err + + toString_majorPrecision: (ut) -> + result = SemanticVersioning\toString 66051, "major" + ut\assertEquals result, "1.0.0" + + -- check + + check_equal: (ut) -> + result, b = SemanticVersioning\check "1.2.3", "1.2.3" + ut\assertTrue result + + check_greater: (ut) -> + result = SemanticVersioning\check "2.0.0", "1.0.0" + ut\assertTrue result + + check_less: (ut) -> + result = SemanticVersioning\check "1.0.0", "2.0.0" + ut\assertFalse result + + check_majorPrecision: (ut) -> + result = SemanticVersioning\check "2.0.0", "1.9.9", "major" + ut\assertTrue result + + check_badArg: (ut) -> + result, err = SemanticVersioning\check "bad", "1.0.0" + ut\assertNil result + ut\assertString err + + _order: { + "toNumber_string", "toNumber_zero", "toNumber_number", "toNumber_nil", + "toNumber_badString", "toNumber_overflow", "toNumber_badType", + "toString_fromNumber", "toString_roundtrip", "toString_majorPrecision", + "check_equal", "check_greater", "check_less", "check_majorPrecision", "check_badArg" + } + } + + Lock: { + _description: "Tests for the Lock cooperative mutex class." + + -- LockState enum: verifies Enum was called with "LockState" and the correct value mapping + + lockState_values: (ut) -> + ut\assertEquals Lock.LockState.Unknown, -1 + ut\assertEquals Lock.LockState.Unavailable, 0 + ut\assertEquals Lock.LockState.Available, 1 + ut\assertEquals Lock.LockState.Held, 2 + + lockState_name: (ut) -> + found, val = Lock.LockState\test "Held" + ut\assertTrue found + ut\assertEquals val, 2 + + -- class-level Logger: verifies Logger was constructed with the correct fileBaseName + + classLogger_fileBaseName: (ut) -> + ut\assertEquals Lock.logger.fileBaseName, "DependencyControl.Lock" + + -- constructor + + new_defaults: (ut) -> + lock = Lock namespace: "ns", resource: "res" + ut\assertEquals lock.namespace, "ns" + ut\assertEquals lock.resource, "res" + ut\assertEquals lock.holderName, "unknown" + ut\assertEquals lock.expiresAfter, 300 + ut\assertString lock.instanceId + + new_customLogger: (ut) -> + customLogger = Logger toFile: false, toWindow: false + lock = Lock namespace: "ns", resource: "res", logger: customLogger + ut\assertEquals lock.logger, customLogger + + -- getState + + getState_initial: (ut) -> + lock = Lock namespace: "ns", resource: "res" + ut\assertEquals lock\getState!, Lock.LockState.Unknown + + getState_held: (ut) -> + (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\returns true + ut\stub TERRIBLE_MUTEX_MODULE_NAME, "unlock" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res" + lock\lock! + ut\assertEquals lock\getState!, Lock.LockState.Held + lock\release! + + -- lock + + lock_success: (ut) -> + tryLockStub = (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\returns true + ut\stub TERRIBLE_MUTEX_MODULE_NAME, "unlock" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res" + state, timePassed = lock\lock! + ut\assertEquals state, Lock.LockState.Held + ut\assertEquals timePassed, 0 + tryLockStub\assertCalledOnce! + lock\release! + + lock_alreadyHeld: (ut) -> + tryLockStub = (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\returns true + ut\stub TERRIBLE_MUTEX_MODULE_NAME, "unlock" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res" + lock\lock! -- acquire + state, timePassed = lock\lock! -- re-enter: already held path + ut\assertEquals state, Lock.LockState.Held + tryLockStub\assertCalledOnce! -- mutex not re-acquired on second call + lock\release! + + lock_timeout: (ut) -> + tryLockStub = (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\returns false + sleepStub = ut\stub TIMER_MODULE_NAME, "sleep" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res" + state, timePassed = lock\lock 0 + ut\assertEquals state, Lock.LockState.Unavailable + tryLockStub\assertCalledOnce! + sleepStub\assertNotCalled! -- timeout=0 suppresses sleep + + lock_retry: (ut) -> + callCount = 0 + tryLockStub = (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\calls -> + callCount += 1 + callCount >= 2 -- fails first, succeeds second + sleepStub = ut\stub TIMER_MODULE_NAME, "sleep" + ut\stub TERRIBLE_MUTEX_MODULE_NAME, "unlock" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res" + state, timePassed = lock\lock! + ut\assertEquals state, Lock.LockState.Held + tryLockStub\assertCalledTimes 2 + sleepStub\assertCalledOnceWith 250 -- default lockWaitInterval + lock\release! + + -- tryLock + + tryLock_success: (ut) -> + tryLockStub = (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\returns true + ut\stub TERRIBLE_MUTEX_MODULE_NAME, "unlock" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res" + state, timePassed = lock\tryLock! + ut\assertEquals state, Lock.LockState.Held + tryLockStub\assertCalledOnce! + lock\release! + + tryLock_fail: (ut) -> + tryLockStub = (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\returns false + ut\stub TIMER_MODULE_NAME, "sleep" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res" + state, timePassed = lock\tryLock! + ut\assertEquals state, Lock.LockState.Unavailable + tryLockStub\assertCalledOnce! + + -- release + + release_held: (ut) -> + (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\returns true + unlockStub = ut\stub TERRIBLE_MUTEX_MODULE_NAME, "unlock" + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res" + lock\lock! + result, extra = lock\release! + ut\assertTrue result + ut\assertEquals extra, Lock.LockState.Available + unlockStub\assertCalledOnce! + + release_notHeld: (ut) -> + ut\stub Lock.logger, "trace" + lock = Lock namespace: "ns", resource: "res" + result, err = lock\release! + ut\assertNil result + ut\assertString err + ut\assertContains err, "not currently held" + + -- GC canary: unreleased lock is cleaned up and warns on collection + + gc_canary: (ut) -> + (ut\stub TERRIBLE_MUTEX_MODULE_NAME, "tryLock")\returns true + unlockStub = ut\stub TERRIBLE_MUTEX_MODULE_NAME, "unlock" + warnStub = ut\stub Lock.logger, "warn" + ut\stub Lock.logger, "trace" + do + lock = Lock namespace: "ns", resource: "res" + lock\lock! + collectgarbage "collect" + collectgarbage "collect" -- second pass needed for __gc finalizers + warnStub\assertCalledOnce! + unlockStub\assertCalledOnce! + + _order: { + "lockState_values", "lockState_name", + "classLogger_fileBaseName", + "new_defaults", "new_customLogger", + "getState_initial", "getState_held", + "lock_success", "lock_alreadyHeld", "lock_timeout", "lock_retry", + "tryLock_success", "tryLock_fail", + "release_held", "release_notHeld", + "gc_canary" + } + } + + ConfigHandler: { + _description: "Tests for the ConfigHandler JSON-backed config manager." + + -- getSerializableCopy: pure static method, no stubs needed + + getSerializableCopy_simple: (ut) -> + result = ConfigHandler\getSerializableCopy {a: 1, b: "hello"} + ut\assertEquals result.a, 1 + ut\assertEquals result.b, "hello" + + getSerializableCopy_privateKeys: (ut) -> + result = ConfigHandler\getSerializableCopy {pub: 1, _priv: 2} + ut\assertEquals result.pub, 1 + ut\assertNil result._priv + + getSerializableCopy_nested: (ut) -> + result = ConfigHandler\getSerializableCopy {outer: {inner: 1, _skip: 2}} + ut\assertEquals result.outer.inner, 1 + ut\assertNil result.outer._skip + + getSerializableCopy_circular: (ut) -> + t = {a: 1} + t.self = t + result = ConfigHandler\getSerializableCopy t + ut\assertEquals result.a, 1 + ut\assertEquals type(result.self), "table" + ut\assertNil result.self.a -- circular ref becomes empty table + + -- new + + new_noPath: (ut) -> + handler = ConfigHandler nil + ut\assertNil handler.filePath + ut\assertNil handler.lock + ut\assertEquals type(handler.config), "table" + + new_withPath: (ut) -> + validateStub = (ut\stub FILEOPS_MODULE_NAME, "validateFullPath")\calls (path) -> path, nil + handler = ConfigHandler "/config/test.json" + ut\assertEquals handler.filePath, "/config/test.json" + ut\assertNotNil handler.lock + validateStub\assertCalledOnceWith "/config/test.json", true + + new_badPath: (ut) -> + (ut\stub FILEOPS_MODULE_NAME, "validateFullPath")\returns nil, "invalid path" + ok, err = pcall -> ConfigHandler "/bad/path.json" + ut\assertFalse ok + + -- getHive: exercises traverseHive + mergeHive internally + + getHive_exists: (ut) -> + handler = ConfigHandler nil + handler.config = {section: {key: "value"}} + hive, err = handler\getHive {"section"} + ut\assertNil err + ut\assertEquals hive.key, "value" + + getHive_missing: (ut) -> + handler = ConfigHandler nil + hive, err = handler\getHive {"section"} + ut\assertNil err + ut\assertEquals type(hive), "table" + ut\assertEquals type(handler.config.section), "table" -- path created in config + + getHive_badParent: (ut) -> + handler = ConfigHandler nil + handler.config = {section: "not_a_table"} + hive, err = handler\getHive {"section", "child"} + ut\assertNil hive + ut\assertString err + + -- getView + + getView_success: (ut) -> + handler = ConfigHandler nil + handler.config = {section: {key: "value"}} + view, err = handler\getView {"section"} + ut\assertNil err + ut\assertNotNil view + ut\assertEquals view.__hivePath[1], "section" + ut\assertEquals #view.__hivePath, 1 + ut\assertTrue handler.views[view] + + getView_failure: (ut) -> + handler = ConfigHandler nil + handler.config = {section: "not_a_table"} + view, err = handler\getView {"section", "child"} + ut\assertNil view + ut\assertString err + + -- getOverlappingViews + + getOverlappingViews_wrongHandler: (ut) -> + handler1 = ConfigHandler nil + handler2 = ConfigHandler nil + view2 = ConfigView handler2, {"section"} + overlaps, err = handler1\getOverlappingViews view2 + ut\assertNil overlaps + ut\assertString err + + getOverlappingViews_found: (ut) -> + handler = ConfigHandler nil + view1 = ConfigView handler, {"section"} + view2 = ConfigView handler, {"section", "child"} + handler.views[view1] = true + handler.views[view2] = true + overlaps, err = handler\getOverlappingViews view1 + ut\assertNil err + ut\assertEquals #overlaps, 1 + ut\assertEquals overlaps[1], view2 + + getOverlappingViews_notFound: (ut) -> + handler = ConfigHandler nil + view1 = ConfigView handler, {"sectionA"} + view2 = ConfigView handler, {"sectionB"} + handler.views[view1] = true + handler.views[view2] = true + overlaps, err = handler\getOverlappingViews view1 + ut\assertNil err + ut\assertEquals #overlaps, 0 + + -- load: stubs fileOps.attributes, lock, io.open, json.decode + + load_noFilePath: (ut) -> + handler = ConfigHandler nil + result, err = handler\load! + ut\assertNil result + ut\assertString err + + load_fileNotFound: (ut) -> + handler = ConfigHandler nil + handler.filePath = "/config/test.json" + (ut\stub FILEOPS_MODULE_NAME, "attributes")\returns false, "/config/test.json" + result = handler\load! + ut\assertTrue result + ut\assertEquals handler.config, {} + + load_success: (ut) -> + handler = ConfigHandler nil + handler.filePath = "/config/test.json" + handler.lock = {} + (ut\stub handler.lock, "lock")\returns Lock.LockState.Held, 0 + ut\stub handler.lock, "release" + (ut\stub FILEOPS_MODULE_NAME, "attributes")\returns "file", "/config/test.json" + openStub = (ut\stub io, "open")\calls -> { + read: (handle, fmt) -> '{"key":"value"}' + close: (handle) -> + } + (ut\stub JSON_MODULE_NAME, "decode")\returns {key: "value"} + result = handler\load! + ut\assertTrue result + ut\assertEquals handler.config.key, "value" + openStub\assertCalledOnceWith "/config/test.json", "r" + + -- save: stubs fileOps.attributes, lock, io.open, json.encode + + save_noFilePath: (ut) -> + handler = ConfigHandler nil + result, err = handler\save! + ut\assertNil result + ut\assertString err + + save_lockFailed: (ut) -> + handler = ConfigHandler nil + handler.filePath = "/config/test.json" + handler.lock = {} + (ut\stub handler.lock, "lock")\returns Lock.LockState.Unavailable, 0 + result, err = handler\save! + ut\assertNil result + ut\assertString err + + save_success: (ut) -> + handler = ConfigHandler nil + handler.filePath = "/config/test.json" + handler.config = {key: "value"} + handler.lock = {} + (ut\stub handler.lock, "lock")\returns Lock.LockState.Held, 0 + ut\stub handler.lock, "release" + -- readFile sees no existing file, save writes fresh + (ut\stub FILEOPS_MODULE_NAME, "attributes")\returns false, "/config/test.json" + writeHandle = {setvbuf: ->, write: ->, flush: ->, close: ->} + openStub = (ut\stub io, "open")\returns writeHandle + (ut\stub JSON_MODULE_NAME, "encode")\returns '{"key":"value"}' + result = handler\save! + ut\assertTrue result + openStub\assertCalledOnceWith "/config/test.json", "w" + + -- save with views: exercises mergeHive + cleanHive + + save_withViewMissingHive: (ut) -> + -- Regression: mirrors the Updater scenario where a virtual module + -- is installed and its config view is switched from an in-memory + -- handler (Handler A) to the real file handler (Handler B). Handler B's + -- @config doesn't yet have this namespace, so mergeHive nils out the + -- view's path in the freshly-read file config, and cleanHive must + -- treat that absence as "nothing to purge" instead of crashing. + + -- Handler A: in-memory only, no file backing (virtual module state) + view = ConfigView\get false, {"section", "key"} + view.userConfig.someField = "data" + + -- Handler B: real file handler — its in-memory @config knows about + -- the section (e.g. other modules) but not this view's specific key + handlerB = ConfigHandler nil + handlerB.filePath = "/config/test.json" + handlerB.config = {section: {}} + handlerB.lock = {} + (ut\stub handlerB.lock, "lock")\returns Lock.LockState.Held, 0 + ut\stub handlerB.lock, "release" + (ut\stub FILEOPS_MODULE_NAME, "attributes")\returns false, "/config/test.json" + (ut\stub io, "open")\returns {setvbuf: ->, write: ->, flush: ->, close: ->} + (ut\stub JSON_MODULE_NAME, "encode")\returns '{}' + + -- Switch the view from Handler A to Handler B (what setFile does + -- under the hood after a virtual module has been installed) + view.__configHandler = handlerB + + result = handlerB\save view + ut\assertTrue result + + save_withViewPopulatedHive: (ut) -> + -- Normal path: cleanHive keeps a hive that has data and save succeeds. + handler = ConfigHandler nil + handler.filePath = "/config/test.json" + handler.config = {section: {key: {value: 42}}} + handler.lock = {} + (ut\stub handler.lock, "lock")\returns Lock.LockState.Held, 0 + ut\stub handler.lock, "release" + (ut\stub FILEOPS_MODULE_NAME, "attributes")\returns false, "/config/test.json" + (ut\stub io, "open")\returns {setvbuf: ->, write: ->, flush: ->, close: ->} + (ut\stub JSON_MODULE_NAME, "encode")\returns '{}' + fakeView = {__hivePath: {"section", "key"}, __class: ConfigView} + result = handler\save fakeView + ut\assertTrue result + + -- purgeHive + + purgeHive_removesPath: (ut) -> + handler = ConfigHandler nil + handler.config = {section: {key: "value"}, other: {x: 1}} + view = ConfigView handler, {"section"} + newHive = handler\purgeHive view + ut\assertEquals type(newHive), "table" + ut\assertNil newHive.key -- original content cleared + ut\assertEquals handler.config.other.x, 1 -- sibling section untouched + + _order: { + "getSerializableCopy_simple", "getSerializableCopy_privateKeys", + "getSerializableCopy_nested", "getSerializableCopy_circular", + "new_noPath", "new_withPath", "new_badPath", + "getHive_exists", "getHive_missing", "getHive_badParent", + "getView_success", "getView_failure", + "getOverlappingViews_wrongHandler", "getOverlappingViews_found", "getOverlappingViews_notFound", + "load_noFilePath", "load_fileNotFound", "load_success", + "save_noFilePath", "save_lockFailed", "save_success", + "save_withViewMissingHive", "save_withViewPopulatedHive", + "purgeHive_removesPath" + } + } + + ConfigView: { + _description: "Tests for the ConfigView hive accessor and defaults proxy." + + -- new + + new_orphan: (ut) -> + view = ConfigView nil, "section" + ut\assertEquals view.__hivePath[1], "section" + ut\assertEquals #view.__hivePath, 1 + ut\assertNil view.__configHandler + ut\assertEquals view.userConfig, {} + ut\assertNil view.file + + new_withHandler: (ut) -> + handler = ConfigHandler nil + handler.filePath = "/test/config.json" + handler.config = {section: {key: "value"}} + view = ConfigView handler, {"section"} + ut\assertEquals view.__configHandler, handler + ut\assertEquals view.userConfig.key, "value" + ut\assertEquals view.file, "/test/config.json" + + new_stringHivePath: (ut) -> + view = ConfigView nil, "mySection" + ut\assertEquals view.__hivePath[1], "mySection" + ut\assertEquals #view.__hivePath, 1 + + new_tableHivePath: (ut) -> + view = ConfigView nil, {"a", "b"} + ut\assertEquals view.__hivePath[1], "a" + ut\assertEquals view.__hivePath[2], "b" + + -- isOverlappingView + + isOverlappingView_differentHandler: (ut) -> + handler1 = ConfigHandler nil + handler2 = ConfigHandler nil + view1 = ConfigView handler1, {"section"} + view2 = ConfigView handler2, {"section"} + result, err = view1\isOverlappingView view2 + ut\assertNil result + ut\assertString err + + isOverlappingView_root: (ut) -> + handler = ConfigHandler nil + root = ConfigView handler, {} + child = ConfigView handler, {"section"} + ut\assertTrue root\isOverlappingView child + + isOverlappingView_overlap: (ut) -> + handler = ConfigHandler nil + parent = ConfigView handler, {"a", "b"} + child = ConfigView handler, {"a", "b", "c"} + ut\assertTrue parent\isOverlappingView child + + isOverlappingView_disjoint: (ut) -> + handler = ConfigHandler nil + viewA = ConfigView handler, {"a"} + viewB = ConfigView handler, {"b"} + ut\assertFalse viewA\isOverlappingView viewB + + -- config proxy: read/write behavior + + config_readUser: (ut) -> + handler = ConfigHandler nil + handler.config = {section: {key: "userValue"}} + view = ConfigView handler, {"section"}, {key: "defaultValue"} + ut\assertEquals view.config.key, "userValue" + + config_readDefault: (ut) -> + handler = ConfigHandler nil + handler.config = {section: {}} + view = ConfigView handler, {"section"}, {key: "defaultValue"} + ut\assertEquals view.config.key, "defaultValue" + + config_write: (ut) -> + handler = ConfigHandler nil + handler.config = {section: {}} + view = ConfigView handler, {"section"} + view.config.newKey = "written" + ut\assertEquals view.userConfig.newKey, "written" + + -- refresh: re-links userConfig to handler's current hive table + + refresh_success: (ut) -> + handler = ConfigHandler nil + handler.config = {section: {key: "initial"}} + view = ConfigView handler, {"section"} + ut\assertEquals view.userConfig.key, "initial" + handler.config.section = {key: "updated"} -- replace table, not just value + view\refresh! + ut\assertEquals view.userConfig.key, "updated" + + -- import + + import_simple: (ut) -> + handler = ConfigHandler nil + handler.config = {section: {}} + view = ConfigView handler, {"section"} + changesMade = view\import {key: "value", num: 42} + ut\assertTrue changesMade + ut\assertEquals view.userConfig.key, "value" + ut\assertEquals view.userConfig.num, 42 + + import_updateOnly: (ut) -> + handler = ConfigHandler nil + handler.config = {section: {existing: "old"}} + view = ConfigView handler, {"section"}, {existing: "default"} + view\import {existing: "new", notExisting: "skip"}, nil, true + ut\assertEquals view.userConfig.existing, "new" + ut\assertNil view.userConfig.notExisting + + import_skipPrivate: (ut) -> + handler = ConfigHandler nil + handler.config = {section: {}} + view = ConfigView handler, {"section"} + view\import {pub: "ok", _priv: "hidden"} + ut\assertEquals view.userConfig.pub, "ok" + ut\assertNil view.userConfig._priv + + -- load / save / delete: stub handler methods, verify delegation + + load_noFilePath: (ut) -> + handler = ConfigHandler nil + handler.config = {section: {}} + view = ConfigView handler, {"section"} + ut\assertFalse view\load! + + load_delegatesToHandler: (ut) -> + handler = ConfigHandler nil + handler.filePath = "/test/config.json" + handler.config = {section: {}} + loadStub = (ut\stub handler, "load")\returns true + view = ConfigView handler, {"section"} + result = view\load 500 + ut\assertTrue result + loadStub\assertCalledOnce! + + save_noFilePath: (ut) -> + handler = ConfigHandler nil + handler.config = {section: {}} + view = ConfigView handler, {"section"} + ut\assertFalse view\save! + + save_delegatesToHandler: (ut) -> + handler = ConfigHandler nil + handler.filePath = "/test/config.json" + handler.config = {section: {}} + saveStub = (ut\stub handler, "save")\returns true + view = ConfigView handler, {"section"} + result = view\save 250 + ut\assertTrue result + saveStub\assertCalledOnce! + + delete_purgesAndSaves: (ut) -> + handler = ConfigHandler nil + handler.filePath = "/test/config.json" + handler.config = {section: {key: "value"}} + newHive = {} + purgeStub = (ut\stub handler, "purgeHive")\returns newHive + saveStub = (ut\stub handler, "save")\returns true + view = ConfigView handler, {"section"} + result = view\delete! + ut\assertTrue result + purgeStub\assertCalledOnce! + saveStub\assertCalledOnce! + ut\assertEquals view.userConfig, newHive + + _order: { + "new_orphan", "new_withHandler", "new_stringHivePath", "new_tableHivePath", + "isOverlappingView_differentHandler", "isOverlappingView_root", + "isOverlappingView_overlap", "isOverlappingView_disjoint", + "config_readUser", "config_readDefault", "config_write", + "refresh_success", + "import_simple", "import_updateOnly", "import_skipPrivate", + "load_noFilePath", "load_delegatesToHandler", + "save_noFilePath", "save_delegatesToHandler", + "delete_purgesAndSaves" + } + } + + ModuleLoader: { + _description: "Tests for ModuleLoader internal module loading helpers." + + -- formatVersionErrorTemplate: pure computation, uses SemanticVersioning.toString + + formatVersionErrorTemplate_missing_bare: (ut) -> + result = ModuleLoader.formatVersionErrorTemplate nil, "MyModule", nil, nil, "not found" + ut\assertString result + ut\assertContains result, "MyModule" + ut\assertContains result, "not found" + + formatVersionErrorTemplate_missing_withVersion: (ut) -> + result = ModuleLoader.formatVersionErrorTemplate nil, "MyModule", "1.0.0", nil, "not found" + ut\assertContains result, "(v1.0.0)" + + formatVersionErrorTemplate_missing_withUrl: (ut) -> + result = ModuleLoader.formatVersionErrorTemplate nil, "MyModule", nil, "http://example.com", "not found" + ut\assertContains result, ": http://example.com" + + formatVersionErrorTemplate_outdated_scalarRef: (ut) -> + ref = {version: 65793} -- 1*65536 + 1*256 + 1 = "1.1.1" in base-256 encoding + result = ModuleLoader.formatVersionErrorTemplate nil, "MyModule", "2.0.0", nil, "too old", ref + ut\assertContains result, "Installed:" + ut\assertContains result, "Required: v2.0.0" + ut\assertContains result, "1.1.1" + + formatVersionErrorTemplate_outdated_tableRef: (ut) -> + ref = {version: {version: 65793}} -- 1*65536 + 1*256 + 1 = "1.1.1" in base-256 encoding + result = ModuleLoader.formatVersionErrorTemplate nil, "MyModule", "2.0.0", nil, "too old", ref + ut\assertContains result, "Installed:" + ut\assertContains result, "1.1.1" + + -- createDummyRef: tests LOADED_MODULES manipulation + + createDummyRef_nonModule: (ut) -> + rec = {scriptType: Common.ScriptType.Automation, __class: {ScriptType: Common.ScriptType}} + result = ModuleLoader.createDummyRef rec + ut\assertNil result + + createDummyRef_newRef: (ut) -> + ns = "test.ModuleLoader.createNew" + rec = {scriptType: Common.ScriptType.Module, namespace: ns, __class: {ScriptType: Common.ScriptType}} + LOADED_MODULES = LOADED_MODULES or {} + LOADED_MODULES[ns] = nil + result = ModuleLoader.createDummyRef rec + ut\assertTrue result + ut\assertNotNil LOADED_MODULES[ns] + ut\assertTrue LOADED_MODULES[ns].__depCtrlDummy + LOADED_MODULES[ns] = nil + + createDummyRef_existingRef: (ut) -> + ns = "test.ModuleLoader.createExisting" + rec = {scriptType: Common.ScriptType.Module, namespace: ns, __class: {ScriptType: Common.ScriptType}} + LOADED_MODULES = LOADED_MODULES or {} + LOADED_MODULES[ns] = {existing: true} + result = ModuleLoader.createDummyRef rec + ut\assertFalse result + LOADED_MODULES[ns] = nil + + -- removeDummyRef: tests LOADED_MODULES manipulation + + removeDummyRef_nonModule: (ut) -> + rec = {scriptType: Common.ScriptType.Automation, __class: {ScriptType: Common.ScriptType}} + result = ModuleLoader.removeDummyRef rec + ut\assertNil result + + removeDummyRef_dummy: (ut) -> + ns = "test.ModuleLoader.removeDummy" + rec = {scriptType: Common.ScriptType.Module, namespace: ns, __class: {ScriptType: Common.ScriptType}} + LOADED_MODULES = LOADED_MODULES or {} + LOADED_MODULES[ns] = {__depCtrlDummy: true} + result = ModuleLoader.removeDummyRef rec + ut\assertTrue result + ut\assertNil LOADED_MODULES[ns] + + removeDummyRef_nonDummy: (ut) -> + ns = "test.ModuleLoader.removeNonDummy" + rec = {scriptType: Common.ScriptType.Module, namespace: ns, __class: {ScriptType: Common.ScriptType}} + LOADED_MODULES = LOADED_MODULES or {} + LOADED_MODULES[ns] = {__depCtrlDummy: false} + result = ModuleLoader.removeDummyRef rec + ut\assertFalse result + LOADED_MODULES[ns] = nil + + -- loadModule: stubs require, controls LOADED_MODULES + + loadModule_cached: (ut) -> + ns = "test.ModuleLoader.cached" + mockRef = {loaded: true} + mdl = {moduleName: ns} + rec = {namespace: "host.Module", __class: {ScriptType: Common.ScriptType, __name: "DependencyControl"}} + LOADED_MODULES = LOADED_MODULES or {} + LOADED_MODULES[ns] = mockRef + result = ModuleLoader.loadModule rec, mdl, false, false + ut\assertEquals result, mockRef + LOADED_MODULES[ns] = nil + + loadModule_success: (ut) -> + ns = "test.ModuleLoader.success" + mockRef = {loaded: true} + mdl = {moduleName: ns} + rec = {namespace: "host.Module", __class: {ScriptType: Common.ScriptType, __name: "DependencyControl"}} + LOADED_MODULES = LOADED_MODULES or {} + LOADED_MODULES[ns] = nil + (ut\stub _G, "require")\calls (name) -> mockRef + result = ModuleLoader.loadModule rec, mdl, false, false + ut\assertEquals result, mockRef + ut\assertEquals mdl._ref, mockRef + LOADED_MODULES[ns] = nil + + loadModule_missing: (ut) -> + ns = "test.ModuleLoader.missing" + mdl = {moduleName: ns} + rec = {namespace: "host.Module", __class: {ScriptType: Common.ScriptType, __name: "DependencyControl"}} + LOADED_MODULES = LOADED_MODULES or {} + LOADED_MODULES[ns] = nil + (ut\stub _G, "require")\calls (name) -> error "module '#{name}' not found: no such file" + result = ModuleLoader.loadModule rec, mdl, false, false + ut\assertNil result + ut\assertTrue mdl._missing + ut\assertNil mdl._error + + loadModule_error: (ut) -> + ns = "test.ModuleLoader.error" + mdl = {moduleName: ns} + rec = {namespace: "host.Module", __class: {ScriptType: Common.ScriptType, __name: "DependencyControl"}} + LOADED_MODULES = LOADED_MODULES or {} + LOADED_MODULES[ns] = nil + (ut\stub _G, "require")\calls (name) -> error "syntax error in module" + result = ModuleLoader.loadModule rec, mdl, false, false + ut\assertNil result + ut\assertFalse mdl._missing + ut\assertString mdl._error + + -- loadModules: stubs loadModule to control loading behavior + + loadModules_skipsModule: (ut) -> + ns = "test.ModuleLoader.skip" + mdl = {moduleName: ns} + loadModuleStub = ut\stub ModuleLoader, "loadModule" + rec = {moduleName: "host.Module", feed: nil, name: "host", + __class: {ScriptType: Common.ScriptType, __name: "DependencyControl", updater: nil}} + success, err = ModuleLoader.loadModules rec, {mdl}, nil, {[ns]: true} + ut\assertTrue success + ut\assertEquals err, "" + loadModuleStub\assertNotCalled! + + loadModules_allLoaded: (ut) -> + ns = "test.ModuleLoader.allLoaded" + mockRef = {loaded: true} + mdl = {moduleName: ns, version: nil, name: ns} + rec = {namespace: "host.Module", moduleName: "host.Module", feed: nil, name: "host", + __class: {ScriptType: Common.ScriptType, __name: "DependencyControl", updater: nil}} + (ut\stub ModuleLoader, "loadModule")\calls (self, m, usePrivate) -> + m._ref = mockRef unless usePrivate + success, err = ModuleLoader.loadModules rec, {mdl} + ut\assertTrue success + ut\assertEquals err, "" + + -- checkOptionalModules: mock self with requiredModules + + checkOptionalModules_noneOptional: (ut) -> + rec = { + name: "test" + requiredModules: {{moduleName: "SomeModule", name: "SomeModule", optional: false}} + __class: {ScriptType: Common.ScriptType, automationDir: {modules: "include"}} + } + result, err = ModuleLoader.checkOptionalModules rec, {"SomeModule"} + ut\assertTrue result + ut\assertNil err + + checkOptionalModules_missingOptional: (ut) -> + rec = { + name: "test" + requiredModules: { + {moduleName: "MissingMod", name: "MissingMod", optional: true, _missing: true, + _reason: "not found", version: nil, url: nil} + } + __class: {ScriptType: Common.ScriptType, automationDir: {modules: "include"}} + } + result, err = ModuleLoader.checkOptionalModules rec, {"MissingMod"} + ut\assertFalse result + ut\assertString err + ut\assertContains err, "MissingMod" + + _order: { + "formatVersionErrorTemplate_missing_bare", "formatVersionErrorTemplate_missing_withVersion", + "formatVersionErrorTemplate_missing_withUrl", + "formatVersionErrorTemplate_outdated_scalarRef", "formatVersionErrorTemplate_outdated_tableRef", + "createDummyRef_nonModule", "createDummyRef_newRef", "createDummyRef_existingRef", + "removeDummyRef_nonModule", "removeDummyRef_dummy", "removeDummyRef_nonDummy", + "loadModule_cached", "loadModule_success", "loadModule_missing", "loadModule_error", + "loadModules_skipsModule", "loadModules_allLoaded", + "checkOptionalModules_noneOptional", "checkOptionalModules_missingOptional" + } + } + + Record: { + _description: "Tests for Record, the core DependencyControl record class." + + checkVersion_equal: (ut) -> + rec = {version: 65793, __class: Record} + ut\assertTruthy Record.checkVersion rec, 65793 + + checkVersion_greater: (ut) -> + rec = {version: 65793, __class: Record} + ut\assertTruthy Record.checkVersion rec, "1.0.0" + + checkVersion_older: (ut) -> + rec = {version: 65793, __class: Record} + ut\assertFalsy Record.checkVersion rec, "2.0.0" + + checkVersion_recordArg: (ut) -> + rec = {version: 65793, __class: Record} + otherRec = {version: 65536, __class: Record} + ut\assertTruthy Record.checkVersion rec, otherRec + + setVersion_validString: (ut) -> + rec = {} + result = Record.setVersion rec, "2.3.4" + ut\assertEquals result, 131844 + ut\assertEquals rec.version, 131844 + + setVersion_validNumber: (ut) -> + rec = {} + result = Record.setVersion rec, 65793 + ut\assertEquals result, 65793 + + setVersion_invalid: (ut) -> + rec = {} + result, err = Record.setVersion rec, "x.y.z" + ut\assertNil result + ut\assertString err + + validateNamespace_valid: (ut) -> + rec = {namespace: "l0.DependencyControl", virtual: false, __class: Record} + ut\assertTrue Record.validateNamespace rec + + validateNamespace_invalid_noDot: (ut) -> + rec = {namespace: "no-dots", virtual: false, __class: Record} + ut\assertFalse Record.validateNamespace rec + + validateNamespace_invalid_trailingDot: (ut) -> + rec = {namespace: "l0.", virtual: false, __class: Record} + ut\assertFalse Record.validateNamespace rec + + validateNamespace_virtual: (ut) -> + rec = {namespace: "bad", virtual: true, __class: Record} + ut\assertTrue Record.validateNamespace rec + + uninstall_virtual: (ut) -> + rec = { + virtual: true, + scriptType: Common.ScriptType.Automation, + name: "TestScript", + __class: {RecordType: Common.RecordType, terms: Common.terms} + } + result, err = Record.uninstall rec + ut\assertNil result + ut\assertString err + ut\assertContains err, "virtual" + + uninstall_unmanaged: (ut) -> + rec = { + virtual: false, + recordType: Common.RecordType.Unmanaged, + scriptType: Common.ScriptType.Module, + name: "TestMod", + __class: {RecordType: Common.RecordType, terms: Common.terms} + } + result, err = Record.uninstall rec + ut\assertNil result + ut\assertString err + ut\assertContains err, "unmanaged" + + getSubmodules_virtual: (ut) -> + rec = { + virtual: true, + recordType: Common.RecordType.Managed, + scriptType: Common.ScriptType.Module, + __class: {RecordType: Common.RecordType, ScriptType: Common.ScriptType} + } + ut\assertNil Record.getSubmodules rec + + getSubmodules_unmanaged: (ut) -> + rec = { + virtual: false, + recordType: Common.RecordType.Unmanaged, + scriptType: Common.ScriptType.Module, + __class: {RecordType: Common.RecordType, ScriptType: Common.ScriptType} + } + ut\assertNil Record.getSubmodules rec + + getSubmodules_nonModule: (ut) -> + rec = { + virtual: false, + recordType: Common.RecordType.Managed, + scriptType: Common.ScriptType.Automation, + __class: {RecordType: Common.RecordType, ScriptType: Common.ScriptType} + } + ut\assertNil Record.getSubmodules rec + + getConfigFileName_basic: (ut) -> + ut\stub(aegisub, "decode_path")\calls (path) -> path + rec = {configFile: "test.json", __class: {configDir: "?user/config"}} + result = Record.getConfigFileName rec + ut\assertString result + ut\assertContains result, "test.json" + ut\assertContains result, "?user/config" + + registerMacro_basic: (ut) -> + registered = {} + ut\stub(aegisub, "register_macro")\calls (...) -> registered[#registered+1] = table.pack ... + updaterMock = {scheduleUpdate: (->), releaseLock: ->} + rec = { + name: "TestScript", + description: "desc", + config: {c: {customMenu: "Automation"}}, + __class: {updater: updaterMock} + } + Record.registerMacro rec, "MyMacro", "My macro", (->) + ut\assertEquals #registered, 1 + ut\assertContains registered[1][1], "MyMacro" + + _order: { + "checkVersion_equal", "checkVersion_greater", "checkVersion_older", "checkVersion_recordArg", + "setVersion_validString", "setVersion_validNumber", "setVersion_invalid", + "validateNamespace_valid", "validateNamespace_invalid_noDot", + "validateNamespace_invalid_trailingDot", "validateNamespace_virtual", + "uninstall_virtual", "uninstall_unmanaged", + "getSubmodules_virtual", "getSubmodules_unmanaged", "getSubmodules_nonModule", + "getConfigFileName_basic", "registerMacro_basic" + } + } + + ScriptUpdateRecord: { + _description: "Tests for ScriptUpdateRecord channel management and update record accessors." + + getChannels_basic: (ut) -> + data = {channels: {release: {default: true, version: "1.0.0", files: {}}, nightly: {version: "2.0.0", files: {}}}, name: "TestScript"} + sur = ScriptUpdateRecord "test.NS", data, {c:{}}, Common.ScriptType.Module, false + channels, default = sur\getChannels! + ut\assertEquals #channels, 2 + ut\assertEquals default, "release" + + getChannels_noDefault: (ut) -> + data = {channels: {alpha: {version: "1.0.0", files: {}}, beta: {version: "2.0.0", files: {}}}, name: "TestScript"} + sur = ScriptUpdateRecord "test.NS", data, {c:{}}, Common.ScriptType.Module, false + _, default = sur\getChannels! + ut\assertNil default + + setChannel_valid: (ut) -> + data = {channels: {release: {default: true, version: "1.0.0", files: {}}, nightly: {version: "2.0.0", files: {}}}, name: "TestScript"} + sur = ScriptUpdateRecord "test.NS", data, {c:{}}, Common.ScriptType.Module, false + success, channel = sur\setChannel "nightly" + ut\assertTrue success + ut\assertEquals channel, "nightly" + ut\assertEquals sur.version, "2.0.0" + + setChannel_invalid: (ut) -> + data = {channels: {release: {default: true, version: "1.0.0", files: {}}}, name: "TestScript"} + sur = ScriptUpdateRecord "test.NS", data, {c:{}}, Common.ScriptType.Module, false + success, channel = sur\setChannel "nonexistent" + ut\assertFalse success + ut\assertEquals channel, "nonexistent" + + checkPlatform_noConstraint: (ut) -> + data = {channels: {release: {default: true, version: "1.0.0", files: {}}}, name: "T"} + sur = ScriptUpdateRecord "test.NS", data, {c:{}}, Common.ScriptType.Module + result, platform = sur\checkPlatform! + ut\assertTrue result + ut\assertString platform + + checkPlatform_currentPlatform: (ut) -> + -- platforms in channel data is copied to the instance via setChannel + data = {channels: {release: {default: true, version: "1.0.0", files: {}, platforms: {Common.platform}}}, name: "T"} + sur = ScriptUpdateRecord "test.NS", data, {c:{}}, Common.ScriptType.Module + result, _ = sur\checkPlatform! + ut\assertTrue result + + checkPlatform_notMatching: (ut) -> + data = {channels: {release: {default: true, version: "1.0.0", files: {}, platforms: {"nonexistent-arch"}}}, name: "T"} + sur = ScriptUpdateRecord "test.NS", data, {c:{}}, Common.ScriptType.Module + result, _ = sur\checkPlatform! + ut\assertFalsy result + + getChangelog_noTable: (ut) -> + data = {channels: {release: {default: true, version: "1.0.0", files: {}}}, name: "T", changelog: "not a table"} + sur = ScriptUpdateRecord "test.NS", data, {c:{}}, Common.ScriptType.Module + ut\assertEquals sur\getChangelog(nil), "" + + getChangelog_inRange: (ut) -> + data = { + channels: {release: {default: true, version: "1.0.0", files: {}}}, + name: "TestScript", + changelog: {["1.0.0"]: {"Initial release"}, ["0.5.0"]: {"Beta"}} + } + sur = ScriptUpdateRecord "test.NS", data, {c:{}}, Common.ScriptType.Module + result = sur\getChangelog nil + ut\assertString result + ut\assertContains result, "TestScript" + ut\assertContains result, "Initial release" + + getChangelog_allOutOfRange: (ut) -> + data = {channels: {release: {default: true, version: "1.0.0", files: {}}}, name: "T", changelog: {["1.0.0"]: {"Initial release"}}} + sur = ScriptUpdateRecord "test.NS", data, {c:{}}, Common.ScriptType.Module + ut\assertEquals sur\getChangelog(nil, "2.0.0"), "" + + _order: { + "getChannels_basic", "getChannels_noDefault", + "setChannel_valid", "setChannel_invalid", + "checkPlatform_noConstraint", "checkPlatform_currentPlatform", "checkPlatform_notMatching", + "getChangelog_noTable", "getChangelog_inRange", "getChangelog_allOutOfRange" + } + } + + UpdateFeed: { + _description: "Tests for UpdateFeed feed data access and script record retrieval." + + getKnownFeeds_noData: (ut) -> + feed = {data: nil, __class: UpdateFeed} + result = UpdateFeed.getKnownFeeds feed + ut\assertTable result + ut\assertEquals #result, 0 + + getKnownFeeds_withData: (ut) -> + feed = { + data: {knownFeeds: {a: "https://example.com/a.json", b: "https://example.com/b.json"}}, + __class: UpdateFeed + } + result = UpdateFeed.getKnownFeeds feed + ut\assertEquals #result, 2 + + getScript_invalidType: (ut) -> + feed = {data: {macros: {}, modules: {}, knownFeeds: {}}, logger: DepCtrl.logger, __class: UpdateFeed} + result, err = UpdateFeed.getScript feed, "test.NS", 99 + ut\assertNil result + ut\assertString err + + getScript_missing: (ut) -> + feed = {data: {macros: {}, modules: {}, knownFeeds: {}}, logger: DepCtrl.logger, __class: UpdateFeed} + result = UpdateFeed.getScript feed, "test.NS", Common.ScriptType.Module + ut\assertFalse result + + getScript_found: (ut) -> + feed = { + data: {modules: {"test.NS": { + channels: {release: {default: true, version: "1.0.0", files: {}}}, + name: "T" + }}, macros: {}, knownFeeds: {}}, + logger: DepCtrl.logger, __class: UpdateFeed + } + sur = UpdateFeed.getScript feed, "test.NS", Common.ScriptType.Module + ut\assertTable sur + ut\assertEquals sur.namespace, "test.NS" + ut\assertEquals sur.activeChannel, "release" + + getMacro_usesAutomationType: (ut) -> + -- getMacro calls @getScript, which requires self.getScript to resolve via colon call. + -- Adding getScript directly to the mock avoids needing a full class metatable. + feed = { + data: {macros: {"test.NS": { + channels: {release: {default: true, version: "1.0.0", files: {}}}, + name: "T" + }}, modules: {}, knownFeeds: {}}, + logger: DepCtrl.logger, __class: UpdateFeed, + getScript: UpdateFeed.getScript + } + sur = UpdateFeed.getMacro feed, "test.NS" + ut\assertTable sur + ut\assertFalse sur.moduleName -- false for Automation (not a module) + + getModule_usesModuleType: (ut) -> + feed = { + data: {modules: {"test.NS": { + channels: {release: {default: true, version: "1.0.0", files: {}}}, + name: "T" + }}, macros: {}, knownFeeds: {}}, + logger: DepCtrl.logger, __class: UpdateFeed, + getScript: UpdateFeed.getScript + } + sur = UpdateFeed.getModule feed, "test.NS" + ut\assertTable sur + ut\assertEquals sur.moduleName, "test.NS" -- set for Module type + + _order: { + "getKnownFeeds_noData", "getKnownFeeds_withData", + "getScript_invalidType", "getScript_missing", "getScript_found", + "getMacro_usesAutomationType", "getModule_usesModuleType" + } + } + } diff --git a/modules/DependencyControl/Timer.moon b/modules/DependencyControl/Timer.moon new file mode 100644 index 0000000..7bdbbfc --- /dev/null +++ b/modules/DependencyControl/Timer.moon @@ -0,0 +1,68 @@ +-- Monotonic timer with millisecond sleep. +-- DepCtrl always uses this FFI-based implementation for consistent behavior. +-- If PT.PreciseTimer has not been loaded by the time this module runs, it is +-- registered under that name so other scripts requiring it get a working timer. + +ffi = require "ffi" + +local getTime, sleep + +if ffi.os == "Windows" + -- Separate pcalls: a Sleep redeclaration conflict must not block QPC/QPF. + pcall ffi.cdef, "int QueryPerformanceCounter(long long *lpPerformanceCount);" + pcall ffi.cdef, "int QueryPerformanceFrequency(long long *lpFrequency);" + pcall ffi.cdef, "unsigned int Sleep(unsigned int dwMilliseconds);" + + freq = ffi.new "long long[1]" + ffi.C.QueryPerformanceFrequency freq + freq = tonumber freq[0] + + counter = ffi.new "long long[1]" + getTime = -> + ffi.C.QueryPerformanceCounter counter + tonumber(counter[0]) / freq + + sleep = (ms) -> ffi.C.Sleep ms + +else + -- CLOCK_MONOTONIC: 1 on Linux, 6 on macOS + CLOCK_MONOTONIC = ffi.os == "OSX" and 6 or 1 + + pcall ffi.cdef, [[ + struct timespec { long tv_sec; long tv_nsec; }; + int clock_gettime(int clk_id, struct timespec *tp); + int poll(struct pollfd *fds, unsigned long nfds, int timeout); + ]] + + ts = ffi.new "struct timespec" + getTime = -> + ffi.C.clock_gettime CLOCK_MONOTONIC, ts + tonumber(ts.tv_sec) + tonumber(ts.tv_nsec) * 1e-9 + + sleep = (ms) -> ffi.C.poll nil, 0, ms + + +class Timer + --- Creates a new timer, capturing the current time as the start point. + new: => + @startTime = getTime! + + --- Returns wall-clock seconds elapsed since construction. + ---@return number seconds + timeElapsed: => + getTime! - @startTime + + --- Sleeps for the given number of milliseconds. + ---@param ms number + sleep: sleep + + @sleep = sleep + + +-- Try loading the real PT.PreciseTimer so other scripts can use it if available. +-- If it's unavailable (no native build, missing dependencies), inject our +-- Timer as a fallback so those scripts still get a working implementation. +if not pcall require, "PT.PreciseTimer" + package.loaded["PT.PreciseTimer"] = Timer + +return Timer diff --git a/modules/DependencyControl/UnitTestSuite.moon b/modules/DependencyControl/UnitTestSuite.moon index 64b9301..8eb63ee 100644 --- a/modules/DependencyControl/UnitTestSuite.moon +++ b/modules/DependencyControl/UnitTestSuite.moon @@ -1,9 +1,11 @@ Logger = require "l0.DependencyControl.Logger" -re = require "aegisub.re" -- make sure tests can be loaded from the test directory package.path ..= aegisub.decode_path("?user/automation/tests") .. "/?.lua;" +Common = require "l0.DependencyControl.Common" +Stub = require "l0.DependencyControl.Stub" + --- A class for all single unit tests. -- Provides useful assertion and logging methods for a user-specified test function. -- @classmod UnitTest @@ -49,13 +51,14 @@ class UnitTest continuous: "Expected table to have continuous numerical keys, but value at index %d of %d was a nil." matches: "String value '%s' didn't match expected %s pattern '%s'." contains: "String value '%s' didn't contain expected substring '%s' (case-%s comparison)." - error: "Expected function to throw an error but it succesfully returned %d values: %s" + error: "Expected function to throw an error but it successfully returned %d values: %s" errorMsgMatches: "Error message '%s' didn't match expected %s pattern '%s'." } formatTemplate: { type: "'%s' of type %s" } + } --- Creates a single unit test. @@ -80,8 +83,11 @@ class UnitTest -- @treturn[2] string the error message describing how the test failed run: (...) => @assertFailed = false + @stubs = {} @logStart! @success, res = xpcall @f, debug.traceback, @, ... + for i = #@stubs, 1, -1 + @stubs[i]\restore! @logResult res return @success, @errMsg @@ -140,59 +146,7 @@ class UnitTest -- for a small performance benefit -- @tparam[opt] string bType the type of the second value -- @treturn boolean `true` if a and b are equal, otherwise `false` - equals: (a, b, aType, bType) -> - -- TODO: support equality comparison of tables used as keys - treeA, treeB, depth = {}, {}, 0 - - recurse = (a, b, aType = type a, bType) -> - -- identical values are equal - return true if a == b - -- only tables can be equal without also being identical - bType or= type b - return false if aType != bType or aType != "table" - - -- perform table equality comparison - return false if #a != #b - - aFieldCnt, bFieldCnt = 0, 0 - local tablesSeenAtKeys - - depth += 1 - treeA[depth], treeB[depth] = a, b - - for k, v in pairs a - vType = type v - if vType == "table" - -- comparing tables is expensive so we should keep a list - -- of keys we can skip checking when iterating table b - tablesSeenAtKeys or= {} - tablesSeenAtKeys[k] = true - - -- detect synchronous circular references to prevent infinite recursion loops - for i = 1, depth - return true if v == treeA[i] and b[k] == treeB[i] - - unless recurse v, b[k], vType - depth -= 1 - return false - - aFieldCnt += 1 - - for k, v in pairs b - continue if tablesSeenAtKeys and tablesSeenAtKeys[k] - if bFieldCnt == aFieldCnt or not recurse v, a[k] - -- no need to check further if the field count is not identical - depth -= 1 - return false - bFieldCnt += 1 - - -- check metatables for equality - res = recurse getmetatable(a), getmetatable b - depth -= 1 - return res - - return recurse a, b, aType, bType - + equals: Common.equals --- Compares equality of two specified tables ignoring table keys. -- The table comparison works much in the same way as @{UnitTest:equals}, @@ -209,61 +163,27 @@ class UnitTest -- ignoring additional items present in a but not in b. -- @tparam[opt=false] bool requireIdenticalItems Enable this option if you require table items to be identical, -- i.e. compared by reference, rather than by equality. - itemsEqual: (a, b, onlyNumKeys = true, ignoreExtraAItems, requireIdenticalItems) -> - seen, aTbls = {}, {} - aCnt, aTblCnt, bCnt = 0, 0, 0 - - findEqualTable = (bTbl) -> - for i, aTbl in ipairs aTbls - if UnitTest.equals aTbl, bTbl - table.remove aTbls, i - seen[aTbl] = nil - return true - return false - - if onlyNumKeys - aCnt, bCnt = #a, #b - return false if not ignoreExtraAItems and aCnt != bCnt - - for v in *a - seen[v] = true - if "table" == type v - aTblCnt += 1 - aTbls[aTblCnt] = v - - for v in *b - -- identical values - if seen[v] - seen[v] = nil - continue - - -- equal values - if type(v) != "table" or requireIdenticalItems or not findEqualTable v - return false - - - else - for _, v in pairs a - aCnt += 1 - seen[v] = true - if "table" == type v - aTblCnt += 1 - aTbls[aTblCnt] = v - - for _, v in pairs b - bCnt += 1 - -- identical values - if seen[v] - seen[v] = nil - continue - - -- equal values - if type(v) != "table" or requireIdenticalItems or not findEqualTable v - return false - - return false if not ignoreExtraAItems and aCnt != bCnt - - return true + itemsEqual: Common.itemsEqual + + --- Replaces tbl[key] with a Stub and registers it for automatic cleanup after the test. + -- If tbl is a string, looks up the module in package.loaded. + -- @tparam table|string tbl the table (or module name) containing the value to replace + -- @tparam string key the field name to stub + -- @treturn Stub + stub: (tbl, key) => + s = Stub tbl, key, @logger, @ + @stubs[#@stubs+1] = s + return s + + --- Wraps tbl[key] with a Stub that forwards all calls to the original. + -- The original value is restored automatically (LIFO) after the test completes. + -- @tparam table|string tbl the table (or module name) containing the value to wrap + -- @tparam string key the field name to spy on + -- @treturn Stub + spy: (tbl, key) => + s = Stub\spy tbl, key, @logger, @ + @stubs[#@stubs+1] = s + return s --- Helper method to mark a test as failed by assertion and throw a specified error message. -- @local @@ -322,12 +242,12 @@ class UnitTest -- @tparam {[string]={value, string}} args a hashtable of argument values and expected types -- indexed by the respective argument names checkArgTypes: (args) => - i, expected, actual = 1 + i = 1 for name, types in pairs args - actual, expected = types[2], type types[1] - continue if expected == "_any" - @logger\assert actual == expected, @@msgs.assert.checkArgTypes, i, name, - expected, @format "type", types[1] + declared, actual = types[2], type types[1] + continue if declared == "_any" + @logger\assert declared == actual, @@msgs.assert.checkArgTypes, i, name, + declared, @format "type", types[1] i += 1 @@ -554,19 +474,14 @@ class UnitTest -- string asserts --- Fails the assertion if a string doesn't match the specified pattern. - -- Supports both Lua and Regex patterns. + -- Accepts a Lua string pattern or a compiled aegisub.re pattern object. -- @tparam string str the input string - -- @tparam string pattern the pattern to be matched against - -- @tparam[opt=false] boolean useRegex Enable this option to use Regex instead of Lua patterns - -- @tparam[optchain] re.Flags flags Any amount of regex flags as defined by the Aegisub re module - -- (see here for details: http://docs.aegisub.org/latest/Automation/Lua/Modules/re/#flags) - assertMatches: (str, pattern, useRegex = false, ...) => - @checkArgTypes { str: {str, "string"}, pattern: {pattern, "string"}, - useRegex: {useRegex, "boolean"} - } - - match = useRegex and re.match(str, pattern, ...) or str\match pattern, ... - @assert match, @@msgs.assert.matches, str, useRegex and "regex" or "Lua", pattern + -- @param pattern string|userdata Lua pattern string or compiled aegisub.re pattern + assertMatches: (str, pattern) => + @checkArgTypes { str: {str, "string"} } + isLuaPattern = type(pattern) == "string" + match = isLuaPattern and str\match(pattern) or pattern\match str + @assert match, @@msgs.assert.matches, str, (isLuaPattern and "Lua" or "regex"), tostring pattern --- Fails the assertion if a string doesn't contain a specified substring. -- Search is case-sensitive by default. @@ -601,21 +516,16 @@ class UnitTest return res[1] --- Fails the assertion if a function call doesn't cause an error message that matches the specified pattern. - -- Supports both Lua and Regex patterns. + -- Accepts a Lua string pattern or a compiled aegisub.re pattern object. -- @tparam function func the function to be called -- @tparam[opt={}] table args a table of any number of arguments to be passed into the function - -- @tparam string pattern the pattern to be matched against - -- @tparam[opt=false] boolean useRegex Enable this option to use Regex instead of Lua patterns - -- @tparam[optchain] re.Flags flags Any amount of regex flags as defined by the Aegisub re module - -- (see here for details: http://docs.aegisub.org/latest/Automation/Lua/Modules/re/#flags) - assertErrorMsgMatches: (func, params = {}, pattern, useRegex = false, ...) => - @checkArgTypes { func: {func, "function"}, params: {params, "table"}, - pattern: {pattern, "string"}, useRegex: {useRegex, "boolean"} - } + -- @param pattern string|userdata Lua pattern string or compiled aegisub.re pattern + assertErrorMsgMatches: (func, params = {}, pattern) => + @checkArgTypes { func: {func, "function"}, params: {params, "table"} } msg = @assertError func, unpack params - - match = useRegex and re.match(msg, pattern, ...) or msg\match pattern, ... - @assert match, @@msgs.assert.errorMsgMatches, msg, useRegex and "regex" or "Lua", pattern + isString = type(pattern) == "string" + match = isString and msg\match(pattern) or pattern\match msg + @assert match, @@msgs.assert.errorMsgMatches, msg, (isString and "Lua" or "regex"), tostring pattern --- A special case of the UnitTest class for a setup routine @@ -731,6 +641,7 @@ class UnitTestClass --- A DependencyControl unit test suite. -- Your test file/module must return a UnitTestSuite object in order to be recognized as a test suite. +-- @class UnitTestSuite class UnitTestSuite msgs = { run: { @@ -753,6 +664,7 @@ class UnitTestSuite @UnitTest = UnitTest @UnitTestClass = UnitTestClass + @Stub = Stub --- Creates a complete unit test suite for a module or automation script. -- Using this constructor will create all test classes and tests automatically. @@ -760,7 +672,7 @@ class UnitTestSuite -- @tparam {[string] = table, ...}|function(self, dependencies, args...) args To create a UnitTest suite, -- you must supply a hashtable of @{UnitTestClass} constructor tables by name. You can either do so directly, -- or wrap it in a function that takes a number of arguments depending on how the tests are registered: - -- * self: the module being testsed (skipped for automation scripts) + -- * self: the module being tested (skipped for automation scripts) -- * dependencies: a numerically keyed table of all the modules required by the tested script/module (in order) -- * args: any additional arguments passed into the @{DependencyControl\registerTests} function. -- Doing so is required to test automation scripts as well as module functions not exposed by its API. @@ -840,4 +752,4 @@ class UnitTestSuite @logger\log msgs.run.success else @logger\log msgs.run.classesFailed, failedCnt, classCnt - return @success, failedCnt > 0 and allFailed or nil \ No newline at end of file + return @success, failedCnt > 0 and allFailed or nil diff --git a/modules/DependencyControl/UpdateFeed.moon b/modules/DependencyControl/UpdateFeed.moon index 7ec8035..d8d79ac 100644 --- a/modules/DependencyControl/UpdateFeed.moon +++ b/modules/DependencyControl/UpdateFeed.moon @@ -1,80 +1,15 @@ json = require "json" -DownloadManager = require "DM.DownloadManager" +DownloadManager = require "l0.DependencyControl.DownloadManager" -DependencyControl = nil Logger = require "l0.DependencyControl.Logger" Common = require "l0.DependencyControl.Common" +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" defaultLogger = Logger fileBaseName: "DepCtrl.UpdateFeed" +ScriptUpdateRecord = require "l0.DependencyControl.ScriptUpdateRecord" -class ScriptUpdateRecord extends Common - msgs = { - errors: { - noActiveChannel: "No active channel." - } - changelog: { - header: "Changelog for %s v%s (released %s):" - verTemplate: "v %s:" - msgTemplate: " • %s" - } - } - - new: (@namespace, @data, @config = {c:{}}, scriptType, autoChannel = true, @logger = defaultLogger) => - DependencyControl or= require "l0.DependencyControl" - @moduleName = scriptType == @@ScriptType.Module and @namespace - @[k] = v for k, v in pairs data - @setChannel! if autoChannel - - - getChannels: => - channels, default = {} - for name, channel in pairs @data.channels - channels[#channels+1] = name - if channel.default and not default - default = name - - return channels, default - - setChannel: (channelName = @config.c.activeChannel) => - with @config.c - .channels, default = @getChannels! - .lastChannel or= channelName or default - channelData = @data.channels[.lastChannel] - @activeChannel = .lastChannel - return false, @activeChannel unless channelData - @[k] = v for k, v in pairs channelData - - @files = @files and [file for file in *@files when not file.platform or file.platform == @@platform] or {} - return true, @activeChannel - - checkPlatform: => - @logger\assert @activeChannel, msgs.errors.noActiveChannel - return not @platforms or ({p,true for p in *@platforms})[@@platform], @@platform - - getChangelog: (versionRecord, minVer = 0) => - return "" unless "table" == type @changelog - maxVer = DependencyControl\parseVersion @version - minVer = DependencyControl\parseVersion minVer - - changelog = {} - for ver, entry in pairs @changelog - ver = DependencyControl\parseVersion ver - verStr = DependencyControl\getVersionString ver - if ver >= minVer and ver <= maxVer - changelog[#changelog+1] = {ver, verStr, entry} - - return "" if #changelog == 0 - table.sort changelog, (a,b) -> a[1]>b[1] - - msg = {msgs.changelog.header\format @name, DependencyControl\getVersionString(@version), @released or ""} - for chg in *changelog - chg[3] = {chg[3]} if type(chg[3]) ~= "table" - if #chg[3] > 0 - msg[#msg+1] = @logger\format msgs.changelog.verTemplate, 1, chg[2] - msg[#msg+1] = @logger\format(msgs.changelog.msgTemplate, 1, entry) for entry in *chg[3] - - return table.concat msg, "\n" - +--- Downloaded and expanded update feed data source. + -- @class UpdateFeed class UpdateFeed extends Common templateData = { maxDepth: 7, @@ -105,6 +40,7 @@ class UpdateFeed extends Common downloadFailed: "Download of feed %s to %s failed (%s)." cantOpen: "Can't open downloaded feed for reading (%s)." parse: "Error parsing feed." + invalidScriptType: "Invalid or unsupported script type: '%s'. Supported types: %s." } } @@ -130,9 +66,13 @@ class UpdateFeed extends Common j += 1 table.sort .sourceAt[i], (a,b) -> return .templates[a].order < .templates[b].order + --- Creates an update feed wrapper and optionally fetches feed data. + -- @param url string + -- @param[opt=true] autoFetch boolean + -- @param[opt] fileName string + -- @param[opt] config table + -- @param[opt] logger Logger new: (@url, autoFetch = true, fileName, @config = {}, @logger = defaultLogger) => - DependencyControl or= require "l0.DependencyControl" - -- fill in missing config values @config[k] = v for k, v in pairs @@defaultConfig when @config[k] == nil @@ -140,19 +80,24 @@ class UpdateFeed extends Common feedsHaveBeenTrimmed or= Logger(fileMatchTemplate: fileMatchTemplate, logDir: @config.downloadPath, maxFiles: 20)\trimFiles! @fileName = fileName or table.concat {@config.downloadPath, fileBaseName, "%04X"\format(math.random 0, 16^4-1), ".json"} + @downloadManager = DownloadManager aegisub.decode_path @config.downloadPath if @@cache[@url] @logger\trace msgs.trace.usingCached @data = @@cache[@url] elseif autoFetch @fetch! - @downloadManager = DownloadManager aegisub.decode_path @config.downloadPath - + --- Returns URLs of all feeds referenced in the knownFeeds section of this feed. + -- @return string[] urls getKnownFeeds: => return {} unless @data return [url for _, url in pairs @data.knownFeeds] -- TODO: maybe also search all requirements for feed URLs + --- Downloads and parses feed JSON data. + -- @param[opt] fileName string + -- @return table|boolean dataOrSuccess + -- @return string|nil err fetch: (fileName) => @fileName = fileName if fileName @@ -182,6 +127,9 @@ class UpdateFeed extends Common @expand! return @data + --- Walks the parsed feed JSON and expands @{template} variables in-place. + -- Called automatically by @{fetch}; results are cached in @data. + -- @return table data expand: => {:templates, :maxDepth, :sourceAt, :rolling, :sourceKeys} = templateData vars, rvars = {}, {i, {} for i=0, maxDepth} @@ -191,7 +139,7 @@ class UpdateFeed extends Common when "string" val = val\gsub "@{(.-):(.-)}", (name, key) -> if type(vars[name]) == "table" or type(rvars[depth+rOff]) == "table" - vars[name][key] or rvars[depth+rOff][name][key] + vars[name] and vars[name][key] or rvars[depth+rOff][name] and rvars[depth+rOff][name][key] val\gsub "@{(.-)}", (name) -> vars[name] or rvars[depth+rOff][name] when "table" {k, expandTemplates v, depth, rOff for k, v in pairs val} @@ -237,14 +185,42 @@ class UpdateFeed extends Common return @data + --- Retrieves a script update record by namespace and type. + -- @param namespace string + -- @param scriptType number|boolean + -- @param[opt] config table + -- @param[opt] autoChannel boolean + -- @return ScriptUpdateRecord|boolean|nil + -- @return string|nil err getScript: (namespace, scriptType, config, autoChannel) => + -- legacy compatibility for <= 0.6.3 + if scriptType == true then scriptType = @@ScriptType.Module + elseif scriptType == false then scriptType = @@ScriptType.Automation + section = @@ScriptType.name.legacy[scriptType] + unless section + err = msgs.errors.invalidScriptType\format scriptType, + table.concat ["#{v} (#{@@ScriptType.name.canonical[v]})" for k, v in pairs @@ScriptType when k != "name"], ", " + return nil, err + scriptData = @data[section][namespace] return false unless scriptData ScriptUpdateRecord namespace, scriptData, config, scriptType, autoChannel, @logger + --- Retrieves an automation script update record by namespace. + -- @param namespace string + -- @param[opt] config table + -- @param[opt] autoChannel boolean + -- @return ScriptUpdateRecord|boolean|nil + -- @return string|nil err getMacro: (namespace, config, autoChannel) => - @getScript namespace, false, config, autoChannel - + @getScript namespace, @@ScriptType.Automation, config, autoChannel + + --- Retrieves a module update record by namespace. + -- @param namespace string + -- @param[opt] config table + -- @param[opt] autoChannel boolean + -- @return ScriptUpdateRecord|boolean|nil + -- @return string|nil err getModule: (namespace, config, autoChannel) => - @getScript namespace, true, config, autoChannel \ No newline at end of file + @getScript namespace, @@ScriptType.Module, config, autoChannel diff --git a/modules/DependencyControl/Updater.moon b/modules/DependencyControl/Updater.moon index 888a105..59a12c3 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/DependencyControl/Updater.moon @@ -1,14 +1,16 @@ lfs = require "lfs" -DownloadManager = require "DM.DownloadManager" -PreciseTimer = require "PT.PreciseTimer" - +DownloadManager = require "l0.DependencyControl.DownloadManager" +Timer = require "l0.DependencyControl.Timer" UpdateFeed = require "l0.DependencyControl.UpdateFeed" fileOps = require "l0.DependencyControl.FileOps" Logger = require "l0.DependencyControl.Logger" Common = require "l0.DependencyControl.Common" ModuleLoader = require "l0.DependencyControl.ModuleLoader" +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" DependencyControl = nil +--- Shared updater error decoding and base behavior. +-- @class UpdaterBase class UpdaterBase extends Common @logger = Logger fileBaseName: "DependencyControl.Updater" msgs = { @@ -21,20 +23,28 @@ class UpdaterBase extends Common [6]: "The %s of %s '%s' failed because no suitable package could be found %s." [5]: "Skipped %s of %s '%s': Another update initiated by %s is already running." [7]: "Skipped %s of %s '%s': An internet connection is currently not available." + [8]: "Couldn't %s %s '%s' because the requested version is invalid: %s" [10]: "Skipped %s of %s '%s': the update task is already running." [15]: "Couldn't %s %s '%s' because its requirements could not be satisfied:" [30]: "Couldn't %s %s '%s': failed to create temporary download directory %s" [35]: "Aborted %s of %s '%s' because the feed contained a missing or malformed SHA-1 hash for file %s." [50]: "Couldn't finish %s of %s '%s' because some files couldn't be moved to their target location:\n" [55]: "%s of %s '%s' succeeded, couldn't be located by the module loader." - [56]: "%s of %s '%s' succeeded, but an error occured while loading the module:\n%s" + [56]: "%s of %s '%s' succeeded, but an error occurred while loading the module:\n%s" [57]: "%s of %s '%s' succeeded, but it's missing a version record." - [58]: "%s of unmanaged %s '%s' succeeded, but an error occured while creating a DependencyControl record: %s" + [58]: "%s of unmanaged %s '%s' succeeded, but an error occurred while creating a DependencyControl record: %s", [100]: "Error (%d) in component %s during %s of %s '%s':\n— %s" } updaterErrorComponent: {"DownloadManager (adding download)", "DownloadManager"} } + --- Converts updater status/error codes into user-facing error messages. + -- @param code number + -- @param name string + -- @param scriptType number + -- @param isInstall boolean + -- @param[opt] detailMsg string + -- @return string getUpdaterErrorMsg: (code, name, scriptType, isInstall, detailMsg) => if code <= -100 -- Generic downstream error @@ -46,6 +56,8 @@ class UpdaterBase extends Common @@terms.scriptType.singular[scriptType], name, detailMsg +--- Mutable execution state for one install/update operation. +-- @class UpdateTask class UpdateTask extends UpdaterBase dlm = DownloadManager! msgs = { @@ -85,19 +97,28 @@ class UpdateTask extends UpdaterBase unknownType: "Skipping file '%s': unknown type '%s'." } refreshRecord: { - unsetVirtual: "Update initated by another macro already fetched %s '%s', switching to update mode." - otherUpdate: "Update initated by another macro already updated %s '%s' to v%s." + unsetVirtual: "Update initiated by another macro already fetched %s '%s', switching to update mode." + otherUpdate: "Update initiated by another macro already updated %s '%s' to v%s." } } - new: (@record, targetVersion = 0, @addFeeds, @exhaustive, @channel, @optional, @updater) => + --- Creates an update task for one record. + -- @param record Record + -- @param[opt=0] targetVersionNumber number + -- @param[opt] addFeeds string[] + -- @param[opt] exhaustive boolean + -- @param[opt] channel string + -- @param[opt] optional boolean + -- @param updater Updater + new: (@record, targetVersionNumber = 0, @addFeeds, @exhaustive, @channel, @optional, @updater) => DependencyControl or= require "l0.DependencyControl" assert @record.__class == DependencyControl, "First parameter must be a #{DependencyControl.__name} object." + assert type(targetVersionNumber) == "number", "Second parameter must be a semantic version number in integer format." @logger = @updater.logger @triedFeeds = {} @status = nil - @targetVersion = DependencyControl\parseVersion targetVersion + @targetVersion = targetVersionNumber -- set UpdateFeed settings @feedConfig = { @@ -108,10 +129,11 @@ class UpdateTask extends UpdaterBase return nil, -1 unless @updater.config.c.updaterEnabled -- TODO: check if this even works return nil, -2 unless @record\validateNamespace! - set: (targetVersion, @addFeeds, @exhaustive, @channel, @optional) => - @targetVersion = DependencyControl\parseVersion targetVersion - return @ - + --- Loads and validates one feed candidate for the current update task. + -- @param feedUrl string + -- @return table|boolean|nil + -- @return string|number|nil + -- @return number|nil checkFeed: (feedUrl) => -- get feed contents feed = UpdateFeed feedUrl, false, nil, @feedConfig, @logger @@ -121,8 +143,9 @@ class UpdateTask extends UpdaterBase return nil, msgs.checkFeed.downloadFailed\format err -- select our script and update channel - updateRecord = feed\getScript @record.namespace, @record.scriptType, @record.config, false + updateRecord, err = feed\getScript @record.namespace, @record.scriptType, @record.config, false unless updateRecord + return nil, err if err return nil, msgs.checkFeed.noData\format @@terms.scriptType.singular[@record.scriptType], @record.name success, currentChannel = updateRecord\setChannel @channel @@ -147,8 +170,13 @@ class UpdateTask extends UpdaterBase return true, updateRecord, version + --- Runs the full update/install flow for this task. + -- @param[opt] waitLock boolean + -- @param[opt] exhaustive boolean + -- @return number statusCode + -- @return any detail run: (waitLock, exhaustive = @updater.config.c.tryAllFeeds or @@exhaustive) => - logUpdateError = (code, extErr, virtual = @virtual) -> + logUpdateError = (code, extErr, virtual = @record.virtual) -> if code < 0 @logger\log @getUpdaterErrorMsg code, @record.name, @record.scriptType, virtual, extErr return code, extErr @@ -161,7 +189,7 @@ class UpdateTask extends UpdaterBase -- check if the script was already updated if @updated and not exhaustive and @record\checkVersion @targetVersion - @logger\log msgs.run.alreadyUpdated, @record.name, DependencyControl\getVersionString @record.version + @logger\log msgs.run.alreadyUpdated, @record.name, SemanticVersioning\toString @record.version return 2 -- build feed list @@ -229,12 +257,12 @@ class UpdateTask extends UpdaterBase -- and the version must at least be that returned by at least one feed if maxVer>0 and not @record.virtual and @targetVersion <= @record.version @logger\log msgs.run.upToDate, @@terms.scriptType.singular[@record.scriptType], - @record.name, DependencyControl\getVersionString @record.version + @record.name, SemanticVersioning\toString @record.version return 0 - res = msgs.run.noFeedAvailExt\format @targetVersion == 0 and "any" or DependencyControl\getVersionString(@targetVersion), - @record.virtual and "no" or DependencyControl\getVersionString(@record.version), - maxVer<1 and "none" or DependencyControl\getVersionString maxVer + res = msgs.run.noFeedAvailExt\format @targetVersion == 0 and "any" or SemanticVersioning\toString(@targetVersion), + @record.virtual and "no" or SemanticVersioning\toString(@record.version), + maxVer<1 and "none" or SemanticVersioning\toString maxVer if @optional @logger\log msgs.run.skippedOptional, @record.name, @@terms.isInstall[@record.virtual], @@ -246,6 +274,10 @@ class UpdateTask extends UpdaterBase code, res = @performUpdate updateRecord return logUpdateError code, res, wasVirtual + --- Downloads and installs files for a selected update entry. + -- @param update ScriptUpdateRecord + -- @return number statusCode + -- @return table|string|nil detail performUpdate: (update) => finish = (...) -> @running = false @@ -285,8 +317,9 @@ class UpdateTask extends UpdaterBase -- download updated scripts to temp directory -- check hashes before download, only update changed files - tmpDir = aegisub.decode_path "?temp/l0.#{DependencyControl.__name}_#{'%04X'\format math.random 0, 16^4-1}" + tmpDir = fileOps.getTempDir! res, dir = fileOps.mkdir tmpDir + return finish -30, "#{tmpDir} (#{dir})" if res == nil @logger\log msgs.performUpdate.updateReady, tmpDir @@ -379,20 +412,22 @@ class UpdateTask extends UpdaterBase @ref = ref else with @record - .name, .version, .virtual = @record.name, DependencyControl\parseVersion update.version + .name = @record.name + .virtual = false + .version = SemanticVersioning\toNumber update.version @record\writeConfig! @updated = true - @logger\log msgs.performUpdate.updSuccess, @@terms.capitalize(@@terms.isInstall[wasVirtual]), + @logger\log msgs.performUpdate.updSuccess, @@terms.capitalize(@@terms.isInstall[wasVirtual or false]), @@terms.scriptType.singular[@record.scriptType], - @record.name, DependencyControl\getVersionString @record.version + @record.name, SemanticVersioning\toString @record.version - -- Diplay changelog - @logger\log update\getChangelog @record, (DependencyControl\parseVersion oldVer) + 1 + -- Display changelog + @logger\log update\getChangelog @record, (SemanticVersioning\toNumber oldVer) + 1 @logger\log msgs.performUpdate.reloadNotice -- TODO: check handling of private module copies (need extra return value?) - return finish 1, DependencyControl\getVersionString @record.version + return finish 1, SemanticVersioning\toString @record.version refreshRecord: => @@ -406,8 +441,10 @@ class UpdateTask extends UpdaterBase @logger\log msgs.refreshRecord.unsetVirtual, @@terms.scriptType.singular[.scriptType], .name else @logger\log msgs.refreshRecord.otherUpdate, @@terms.scriptType.singular[.scriptType], .name, - DependencyControl\getVersionString @record.version + SemanticVersioning\toString @record.version +--- Coordinates background update checks and update task lifecycle. +-- @class Updater class Updater extends UpdaterBase msgs = { getLock: { @@ -425,9 +462,23 @@ class Updater extends UpdaterBase runningUpdate: "Running scheduled update for %s '%s'..." } } + --- Creates an updater coordinator for one host script context. + -- @param[opt] host string + -- @param config ConfigHandler + -- @param[opt] logger Logger new: (@host = script_namespace, @config, @logger = @@logger) => @tasks = {scriptType, {} for _, scriptType in pairs @@ScriptType when "number" == type scriptType} + --- Creates or updates a queued update task for a record. + -- @param record Record|table + -- @param[opt] targetVersion number|string + -- @param[opt] addFeeds string[] + -- @param[opt] exhaustive boolean + -- @param[opt] channel string + -- @param[opt] optional boolean + -- @return UpdateTask|nil + -- @return number|nil code + -- @return string|nil detail addTask: (record, targetVersion, addFeeds = {}, exhaustive, channel, optional) => DependencyControl or= require "l0.DependencyControl" if record.__class != DependencyControl @@ -435,18 +486,28 @@ class Updater extends UpdaterBase depRec[k] = v for k, v in pairs record record = DependencyControl depRec - task = @tasks[record.scriptType][record.namespace] - if task - return task\set targetVersion, addFeeds, exhaustive, channel, optional - else - task, err = UpdateTask record, targetVersion, addFeeds, exhaustive, channel, optional, @ - @tasks[record.scriptType][record.namespace] = task - return task, err + targetVersionNumber, err = SemanticVersioning\toNumber targetVersion + if (err) then return nil, -8, err + task = @tasks[record.scriptType][record.namespace] + return if task then with task + .targetVersion = targetVersionNumber + .addFeeds, .exhaustive, .channel, .optional = addFeeds, exhaustive, channel, optional + + task, code = UpdateTask record, targetVersionNumber, addFeeds, exhaustive, channel, optional, @ + @tasks[record.scriptType][record.namespace] = task + return task, code + + --- Ensures a module dependency is installed/updated and loadable. + -- @param record Record + -- @param[opt] ... any + -- @return any + -- @return number|nil code + -- @return string|nil detail require: (record, ...) => @logger\assert record.scriptType == @@ScriptType.Module, msgs.require, record.name or record.namespace @logger\log "%s module '%s'...", record.virtual and "Installing required" or "Updating outdated", record.name - task, code = @addTask record, ... + task, code, res = @addTask record, ... code, res = task\run true if task if code == 0 and not task.updated @@ -459,6 +520,9 @@ class Updater extends UpdaterBase else -- pass on update errors return nil, code, res + --- Performs a periodic non-blocking update check for a managed record. + -- @param record Record + -- @return number|boolean scheduleUpdate: (record) => unless @config.c.updaterEnabled @logger\trace msgs.scheduleUpdate.updaterDisabled, record.name or record.namespace @@ -480,6 +544,11 @@ class Updater extends UpdaterBase return task\run! + --- Acquires the global updater lock shared across scripts. + -- @param doWait boolean + -- @param[opt] waitTimeout number + -- @return boolean + -- @return string|nil lockOwner getLock: (doWait, waitTimeout = @config.c.updateWaitTimeout) => return true if @hasLock @@ -493,7 +562,7 @@ class Updater extends UpdaterBase @logger\log msgs.getLock.waiting, running.host timeout, didWait = waitTimeout, true while running and timeout > 0 - PreciseTimer.sleep 1000 + Timer.sleep 1000 timeout -= 1 @config\load! running = @config.c.updaterRunning @@ -517,8 +586,10 @@ class Updater extends UpdaterBase return true + --- Releases the global updater lock. + -- @return boolean releaseLock: => return false unless @hasLock @hasLock = false @config.c.updaterRunning = false - @config\write! \ No newline at end of file + @config\write! diff --git a/modules/dkjson.moon b/modules/dkjson.moon new file mode 100644 index 0000000..b6df2c9 --- /dev/null +++ b/modules/dkjson.moon @@ -0,0 +1,28 @@ +-- DependencyControl wrapper around the vendored upstream dkjson. +-- +-- The upstream library is kept pristine and unmodified at `modules/dkjson/vendor/dkjson.lua` +-- so it can be updated by dropping in a new copy. The wrapper is a thin overlay that only +-- carries a DependencyControl version record and defers everything else to the upstream module. +-- +-- Resolving the bare module specifiers this module `provides` ("json", "dkjson") is +-- handled by DependencyControl's module searcher. Locally installed copies of dkjson, +-- luajson or any other JSON module will take precedence over this one if imported +-- via bare specifier. + +dkjson = require "l0.dkjson.vendor.dkjson" + +wrapper = setmetatable {}, __index: dkjson + +wrapper.__depCtrlInit = (DependencyControl) -> + wrapper.version = DependencyControl { + name: "dkjson" + version: "2.10.0" + description: "David Kolf's JSON module for Lua." + author: "David Kolf" + moduleName: "l0.dkjson" + url: "http://dkolf.de/dkjson-lua/" + feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/DependencyControl.json" + provides: {"json", "dkjson"} + } + +return wrapper diff --git a/modules/dkjson/vendor/dkjson.lua b/modules/dkjson/vendor/dkjson.lua new file mode 100644 index 0000000..862eea9 --- /dev/null +++ b/modules/dkjson/vendor/dkjson.lua @@ -0,0 +1,810 @@ +-- Module options: +local always_use_lpeg = false +local register_global_module_table = false +local global_module_name = 'json' + +--[==[ + +David Kolf's JSON module for Lua 5.1 - 5.5 + +Version 2.10 + + +For the documentation see the corresponding readme.txt or visit +. + +You can contact the author by sending an e-mail to 'david' at the +domain 'dkolf.de'. + + +Copyright (C) 2010-2026 David Heiko Kolf + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--]==] + +-- global dependencies: +local pairs, type, tostring, tonumber, getmetatable, setmetatable = + pairs, type, tostring, tonumber, getmetatable, setmetatable +local error, require, pcall, select = error, require, pcall, select +local floor, huge = math.floor, math.huge +local strrep, gsub, strsub, strbyte, strchar, strfind, strlen, strformat = + string.rep, string.gsub, string.sub, string.byte, string.char, + string.find, string.len, string.format +local strmatch = string.match +local concat = table.concat + +local json = { version = "dkjson 2.10" } + +local jsonlpeg = {} + +if register_global_module_table then + if always_use_lpeg then + _G[global_module_name] = jsonlpeg + else + _G[global_module_name] = json + end +end + +local _ENV = nil -- blocking globals in Lua 5.2 and later + +pcall (function() + -- Enable access to blocked metatables. + -- Don't worry, this module doesn't change anything in them. + local debmeta = require "debug".getmetatable + if debmeta then getmetatable = debmeta end +end) + +json.null = setmetatable ({}, { + __tojson = function () return "null" end +}) + +local function isarray (tbl) + local max, n, arraylen = 0, 0, 0 + for k,v in pairs (tbl) do + if k == 'n' and type(v) == 'number' then + arraylen = v + if v > max then + max = v + end + else + if type(k) ~= 'number' or k < 1 or floor(k) ~= k then + return false + end + if k > max then + max = k + end + n = n + 1 + end + end + if max > 10 and max > arraylen and max > n * 2 then + return false -- don't create an array with too many holes + end + return true, max +end + +local escapecodes = { + ["\""] = "\\\"", ["\\"] = "\\\\", ["\b"] = "\\b", ["\f"] = "\\f", + ["\n"] = "\\n", ["\r"] = "\\r", ["\t"] = "\\t" +} + +local function escapeutf8 (uchar) + local value = escapecodes[uchar] + if value then + return value + end + local a, b, c, d = strbyte (uchar, 1, 4) + a, b, c, d = a or 0, b or 0, c or 0, d or 0 + if a <= 0x7f then + value = a + elseif 0xc0 <= a and a <= 0xdf and b >= 0x80 then + value = (a - 0xc0) * 0x40 + b - 0x80 + elseif 0xe0 <= a and a <= 0xef and b >= 0x80 and c >= 0x80 then + value = ((a - 0xe0) * 0x40 + b - 0x80) * 0x40 + c - 0x80 + elseif 0xf0 <= a and a <= 0xf7 and b >= 0x80 and c >= 0x80 and d >= 0x80 then + value = (((a - 0xf0) * 0x40 + b - 0x80) * 0x40 + c - 0x80) * 0x40 + d - 0x80 + else + return "" + end + if value <= 0xffff then + return strformat ("\\u%.4x", value) + elseif value <= 0x10ffff then + -- encode as UTF-16 surrogate pair + value = value - 0x10000 + local highsur, lowsur = 0xD800 + floor (value/0x400), 0xDC00 + (value % 0x400) + return strformat ("\\u%.4x\\u%.4x", highsur, lowsur) + else + return "" + end +end + +local function fsub (str, pattern, repl) + -- gsub always builds a new string in a buffer, even when no match + -- exists. First using find should be more efficient when most strings + -- don't contain the pattern. + if strfind (str, pattern) then + return gsub (str, pattern, repl) + else + return str + end +end + +local function quotestring (value) + -- based on the regexp "escapable" in https://github.com/douglascrockford/JSON-js + value = fsub (value, "[%z\1-\31\"\\\127]", escapeutf8) + if strfind (value, "[\194\216\220\225\226\239]") then + value = fsub (value, "\194[\128-\159\173]", escapeutf8) + value = fsub (value, "\216[\128-\132]", escapeutf8) + value = fsub (value, "\220\143", escapeutf8) + value = fsub (value, "\225\158[\180\181]", escapeutf8) + value = fsub (value, "\226\128[\140-\143\168-\175]", escapeutf8) + value = fsub (value, "\226\129[\160-\175]", escapeutf8) + value = fsub (value, "\239\187\191", escapeutf8) + value = fsub (value, "\239\191[\176-\191]", escapeutf8) + end + return "\"" .. value .. "\"" +end +json.quotestring = quotestring + +local function replace(str, o, n) + local i, j = strfind (str, o, 1, true) + if i then + return strsub(str, 1, i-1) .. n .. strsub(str, j+1, -1) + else + return str + end +end + +-- locale independent num2str and str2num functions +local decpoint, numfilter + +local function updatedecpoint () + decpoint = strmatch(tostring(0.5), "([^05+])") + -- build a filter that can be used to remove group separators + numfilter = "[^0-9%-%+eE" .. gsub(decpoint, "[%^%$%(%)%%%.%[%]%*%+%-%?]", "%%%0") .. "]+" +end + +updatedecpoint() + +local function num2str (num) + return replace(fsub(tostring(num), numfilter, ""), decpoint, ".") +end + +local function str2num (str) + local num = tonumber(replace(str, ".", decpoint)) + if not num then + updatedecpoint() + num = tonumber(replace(str, ".", decpoint)) + end + return num +end + +local function addnewline2 (level, buffer, buflen) + buffer[buflen+1] = "\n" + buffer[buflen+2] = strrep (" ", level) + buflen = buflen + 2 + return buflen +end + +function json.addnewline (state) + if state.indent then + state.bufferlen = addnewline2 (state.level or 0, + state.buffer, state.bufferlen or #(state.buffer)) + end +end + +local encode2 -- forward declaration + +local function addpair (key, value, prev, indent, level, buffer, buflen, tables, globalorder, state) + local kt = type (key) + if kt ~= 'string' and kt ~= 'number' then + return nil, "type '" .. kt .. "' is not supported as a key by JSON." + end + if prev then + buflen = buflen + 1 + buffer[buflen] = "," + end + if indent then + buflen = addnewline2 (level, buffer, buflen) + end + -- When Lua is compiled with LUA_NOCVTN2S this will fail when + -- numbers are mixed into the keys of the table. JSON keys are always + -- strings, so this would be an implicit conversion too and the failure + -- is intentional. + buffer[buflen+1] = quotestring (key) + buffer[buflen+2] = ":" + return encode2 (value, indent, level, buffer, buflen + 2, tables, globalorder, state) +end + +local function appendcustom(res, buffer, state) + local buflen = state.bufferlen + if type (res) == 'string' then + buflen = buflen + 1 + buffer[buflen] = res + end + return buflen +end + +local function exception(reason, value, state, buffer, buflen, defaultmessage) + defaultmessage = defaultmessage or reason + local handler = state.exception + if not handler then + return nil, defaultmessage + else + state.bufferlen = buflen + local ret, msg = handler (reason, value, state, defaultmessage) + if not ret then return nil, msg or defaultmessage end + return appendcustom(ret, buffer, state) + end +end + +function json.encodeexception(reason, value, state, defaultmessage) + return quotestring("<" .. defaultmessage .. ">") +end + +encode2 = function (value, indent, level, buffer, buflen, tables, globalorder, state) + local valtype = type (value) + local valmeta = getmetatable (value) + valmeta = type (valmeta) == 'table' and valmeta -- only tables + local valtojson = valmeta and valmeta.__tojson + if valtojson then + if tables[value] then + return exception('reference cycle', value, state, buffer, buflen) + end + tables[value] = true + state.bufferlen = buflen + local ret, msg = valtojson (value, state) + if not ret then return exception('custom encoder failed', value, state, buffer, buflen, msg) end + tables[value] = nil + buflen = appendcustom(ret, buffer, state) + elseif value == nil then + buflen = buflen + 1 + buffer[buflen] = "null" + elseif valtype == 'number' then + local s + if value ~= value or value >= huge or -value >= huge then + -- This is the behaviour of the original JSON implementation. + s = "null" + else + s = num2str (value) + end + buflen = buflen + 1 + buffer[buflen] = s + elseif valtype == 'boolean' then + buflen = buflen + 1 + buffer[buflen] = value and "true" or "false" + elseif valtype == 'string' then + buflen = buflen + 1 + buffer[buflen] = quotestring (value) + elseif valtype == 'table' then + if tables[value] then + return exception('reference cycle', value, state, buffer, buflen) + end + tables[value] = true + level = level + 1 + local isa, n = isarray (value) + if n == 0 and valmeta and valmeta.__jsontype == 'object' then + isa = false + end + local msg + if isa then -- JSON array + buflen = buflen + 1 + buffer[buflen] = "[" + for i = 1, n do + buflen, msg = encode2 (value[i], indent, level, buffer, buflen, tables, globalorder, state) + if not buflen then return nil, msg end + if i < n then + buflen = buflen + 1 + buffer[buflen] = "," + end + end + buflen = buflen + 1 + buffer[buflen] = "]" + else -- JSON object + local prev = false + buflen = buflen + 1 + buffer[buflen] = "{" + local order = valmeta and valmeta.__jsonorder or globalorder + if order then + local used = {} + n = #order + for i = 1, n do + local k = order[i] + local v = value[k] + if v ~= nil then + used[k] = true + buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state) + if not buflen then return nil, msg end + prev = true -- add a seperator before the next element + end + end + for k,v in pairs (value) do + if not used[k] then + buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state) + if not buflen then return nil, msg end + prev = true -- add a seperator before the next element + end + end + else -- unordered + for k,v in pairs (value) do + buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state) + if not buflen then return nil, msg end + prev = true -- add a seperator before the next element + end + end + if indent then + buflen = addnewline2 (level - 1, buffer, buflen) + end + buflen = buflen + 1 + buffer[buflen] = "}" + end + tables[value] = nil + else + return exception ('unsupported type', value, state, buffer, buflen, + "type '" .. valtype .. "' is not supported by JSON.") + end + return buflen +end + +function json.encode (value, state) + state = state or {} + local oldbuffer = state.buffer + local buffer = oldbuffer or {} + state.buffer = buffer + updatedecpoint() + local ret, msg = encode2 (value, state.indent, state.level or 0, + buffer, state.bufferlen or 0, state.tables or {}, state.keyorder, state) + if not ret then + error (msg, 2) + elseif oldbuffer == buffer then + state.bufferlen = ret + return true + else + state.bufferlen = nil + state.buffer = nil + return concat (buffer) + end +end + +local function loc (str, where) + local line, pos, linepos = 1, 1, 0 + while true do + pos = strfind (str, "\n", pos, true) + if pos and pos < where then + line = line + 1 + linepos = pos + pos = pos + 1 + else + break + end + end + return strformat ("line %d, column %d", line, where - linepos) +end + +local function unterminated (str, what, where) + return nil, strlen (str) + 1, "unterminated " .. what .. " at " .. loc (str, where) +end + +local function scanwhite (str, pos) + while true do + pos = strmatch (str, "%s*()", pos) + local n1, n2, n3 = strbyte (str, pos, pos + 2) + if n1 == 239 and n2 == 187 and n3 == 191 then + -- UTF-8 Byte Order Mark + pos = pos + 3 + elseif n1 == 47 then + if n2 == 47 then -- "//" + pos = strfind (str, "[\n\r]", pos + 2) + if not pos then return nil end + elseif n2 == 42 then -- "/*" + pos = strfind (str, "*/", pos + 2) + if not pos then return nil end + pos = pos + 2 + else + return pos, n1 + end + elseif n1 == nil then + return nil + else + return pos, n1 + end + end +end + +local escapechars = { + ["\""] = "\"", ["\\"] = "\\", ["/"] = "/", ["b"] = "\b", ["f"] = "\f", + ["n"] = "\n", ["r"] = "\r", ["t"] = "\t" +} + +local function unichar (value) + if value < 0 then + return nil + elseif value <= 0x007f then + return strchar (value) + elseif value <= 0x07ff then + return strchar (0xc0 + floor(value/0x40), + 0x80 + (floor(value) % 0x40)) + elseif value <= 0xffff then + return strchar (0xe0 + floor(value/0x1000), + 0x80 + (floor(value/0x40) % 0x40), + 0x80 + (floor(value) % 0x40)) + elseif value <= 0x10ffff then + return strchar (0xf0 + floor(value/0x40000), + 0x80 + (floor(value/0x1000) % 0x40), + 0x80 + (floor(value/0x40) % 0x40), + 0x80 + (floor(value) % 0x40)) + else + return nil + end +end + +local function scanstring (str, pos) + local lastpos = pos + 1 + local buffer, n = {}, 0 + while true do + local nextpos = strfind (str, "[\"\\]", lastpos) + if not nextpos then + return unterminated (str, "string", pos) + end + if nextpos > lastpos then + n = n + 1 + buffer[n] = strsub (str, lastpos, nextpos - 1) + end + if strbyte (str, nextpos) == 34 then -- '"' + lastpos = nextpos + 1 + break + else + local escchar = strsub (str, nextpos + 1, nextpos + 1) + local value + if escchar == "u" then + value = tonumber (strsub (str, nextpos + 2, nextpos + 5), 16) + if value then + local value2 + if 0xD800 <= value and value <= 0xDBff then + -- we have the high surrogate of UTF-16. Check if there is a + -- low surrogate escaped nearby to combine them. + if strsub (str, nextpos + 6, nextpos + 7) == "\\u" then + value2 = tonumber (strsub (str, nextpos + 8, nextpos + 11), 16) + if value2 and 0xDC00 <= value2 and value2 <= 0xDFFF then + value = (value - 0xD800) * 0x400 + (value2 - 0xDC00) + 0x10000 + else + value2 = nil -- in case it was out of range for a low surrogate + end + end + end + value = value and unichar (value) + if value then + if value2 then + lastpos = nextpos + 12 + else + lastpos = nextpos + 6 + end + end + end + end + if not value then + value = escapechars[escchar] or escchar + lastpos = nextpos + 2 + end + n = n + 1 + buffer[n] = value + end + end + if n == 1 then + return buffer[1], lastpos + elseif n > 1 then + return concat (buffer), lastpos + else + return "", lastpos + end +end + +local scanvalue -- forward declaration + +local function scanobject (str, startpos, nullval, objectmeta, arraymeta) + local tbl = setmetatable ({}, objectmeta) + local pos = startpos + 1 + + while true do + local char + pos, char = scanwhite (str, pos) + local key, err + if char == 34 then -- '"' + key, pos, err = scanstring (str, pos) + elseif char == 125 then -- "}" + return tbl, pos + 1 + elseif not pos then + return unterminated (str, "object", startpos) + else + return nil, pos, "invalid key at " .. loc (str, pos) + end + if err then return nil, pos, err end + + char = strbyte (str, pos) + if char ~= 58 then -- ":" + pos, char = scanwhite (str, pos) + if char ~= 58 then + return nil, pos, "missing colon at " .. loc (str, pos) + end + end + + pos = scanwhite (str, pos + 1) + if not pos then return unterminated (str, "object", startpos) end + local val + val, pos, err = scanvalue (str, pos, nullval, objectmeta, arraymeta) + if err then return nil, pos, err end + tbl[key] = val + + char = strbyte (str, pos) + if char == 44 then -- "," + pos = pos + 1 + else + pos, char = scanwhite (str, pos) + + if char == 44 then + pos = pos + 1 + elseif not pos then + return unterminated (str, "object", startpos) + end + end + end +end + +local function scanarray (str, startpos, nullval, objectmeta, arraymeta) + local tbl, n = setmetatable ({}, arraymeta), 0 + local pos = startpos + 1 + + while true do + local char + pos, char = scanwhite (str, pos) + + if char == 93 then -- "]" + return tbl, pos + 1 + elseif not pos then + return unterminated (str, "array", startpos) + end + + local val, err + val, pos, err = scanvalue (str, pos, nullval, objectmeta, arraymeta) + if err then return nil, pos, err end + n = n + 1 + tbl[n] = val + + char = strbyte (str, pos) + if char == 44 then -- "," + pos = pos + 1 + else + pos, char = scanwhite (str, pos) + + if char == 44 then + pos = pos + 1 + elseif not pos then + return unterminated (str, "array", startpos) + end + end + end +end + +local function scaninvalid (str, pos) + return nil, pos, "no valid JSON value at " .. loc (str, pos) +end + +local function scanliteral (str, pos, expected, value) + local pstart, pend = strfind (str, "^%a%w*", pos) + local name = strsub (str, pstart, pend) + if name == expected then + return value, pend + 1 + else + return scaninvalid (str, pos) + end +end + +local function scannumber (str, pos) + local pstart, pend = strfind (str, "^%-?[%d%.]*[eE]?[%+%-]?%d*", pos) + local number = str2num (strsub (str, pstart, pend)) + if number then + return number, pend + 1 + else + return scaninvalid (str, pos) + end +end + +scanvalue = function (str, pos, nullval, objectmeta, arraymeta) + pos = pos or 1 + local c + pos, c = scanwhite (str, pos) + + if c == 34 then -- '"' + return scanstring (str, pos) + elseif c == 123 then -- "{" + return scanobject (str, pos, nullval, objectmeta, arraymeta) + elseif c == 91 then -- "[" + return scanarray (str, pos, nullval, objectmeta, arraymeta) + elseif c == 45 or (c >= 48 and c <= 57) then -- "-", "0"..."9" + return scannumber (str, pos) + elseif c == 116 then -- "t" + return scanliteral (str, pos, "true", true) + elseif c == 102 then -- "f" + return scanliteral (str, pos, "false", false) + elseif c == 110 then -- "n" + return scanliteral (str, pos, "null", nullval) + elseif not pos then + return nil, strlen (str) + 1, "no valid JSON value (reached the end)" + else + return scaninvalid (str, pos) + end +end + +local function optionalmetatables(...) + if select("#", ...) > 0 then + return ... + else + return {__jsontype = 'object'}, {__jsontype = 'array'} + end +end + +function json.decode (str, pos, nullval, ...) + local objectmeta, arraymeta = optionalmetatables(...) + return scanvalue (str, pos, nullval, objectmeta, arraymeta) +end + +function json.use_lpeg () + local g = require ("lpeg") + + if type(g.version) == 'function' and g.version() == "0.11" then + error "due to a bug in LPeg 0.11, it cannot be used for JSON matching" + end + + local pegmatch = g.match + local P, S, R = g.P, g.S, g.R + + local function ErrorCall (str, pos, msg, state) + if not state.msg then + state.msg = msg .. " at " .. loc (str, pos) + state.pos = pos + end + return false + end + + local function Err (msg) + return g.Cmt (g.Cc (msg) * g.Carg (2), ErrorCall) + end + + local function ErrorUnterminatedCall (str, pos, what, state) + return ErrorCall (str, pos - 1, "unterminated " .. what, state) + end + + local SingleLineComment = P"//" * (1 - S"\n\r")^0 + local MultiLineComment = P"/*" * (1 - P"*/")^0 * P"*/" + local Space = (S" \n\r\t" + P"\239\187\191" + SingleLineComment + MultiLineComment)^0 + + local function ErrUnterminated (what) + return g.Cmt (g.Cc (what) * g.Carg (2), ErrorUnterminatedCall) + end + + local PlainChar = 1 - S"\"\\\n\r" + local EscapeSequence = (P"\\" * g.C (S"\"\\/bfnrt" + Err "unsupported escape sequence")) / escapechars + local HexDigit = R("09", "af", "AF") + local function UTF16Surrogate (match, pos, high, low) + high, low = tonumber (high, 16), tonumber (low, 16) + if 0xD800 <= high and high <= 0xDBff and 0xDC00 <= low and low <= 0xDFFF then + return true, unichar ((high - 0xD800) * 0x400 + (low - 0xDC00) + 0x10000) + else + return false + end + end + local function UTF16BMP (hex) + return unichar (tonumber (hex, 16)) + end + local U16Sequence = (P"\\u" * g.C (HexDigit * HexDigit * HexDigit * HexDigit)) + local UnicodeEscape = g.Cmt (U16Sequence * U16Sequence, UTF16Surrogate) + U16Sequence/UTF16BMP + local Char = UnicodeEscape + EscapeSequence + PlainChar + local String = P"\"" * (g.Cs (Char ^ 0) * P"\"" + ErrUnterminated "string") + local Integer = P"-"^(-1) * (P"0" + (R"19" * R"09"^0)) + local Fractal = P"." * R"09"^0 + local Exponent = (S"eE") * (S"+-")^(-1) * R"09"^1 + local Number = (Integer * Fractal^(-1) * Exponent^(-1))/str2num + local Constant = P"true" * g.Cc (true) + P"false" * g.Cc (false) + P"null" * g.Carg (1) + local SimpleValue = Number + String + Constant + local ArrayContent, ObjectContent + + -- The functions parsearray and parseobject parse only a single value/pair + -- at a time and store them directly to avoid hitting the LPeg limits. + local function parsearray (str, pos, nullval, state) + local obj, cont + local start = pos + local npos + local t, nt = {}, 0 + repeat + obj, cont, npos = pegmatch (ArrayContent, str, pos, nullval, state) + if cont == 'end' then + return ErrorUnterminatedCall (str, start, "array", state) + end + pos = npos + if cont == 'cont' or cont == 'last' then + nt = nt + 1 + t[nt] = obj + end + until cont ~= 'cont' + return pos, setmetatable (t, state.arraymeta) + end + + local function parseobject (str, pos, nullval, state) + local obj, key, cont + local start = pos + local npos + local t = {} + repeat + key, obj, cont, npos = pegmatch (ObjectContent, str, pos, nullval, state) + if cont == 'end' then + return ErrorUnterminatedCall (str, start, "object", state) + end + pos = npos + if cont == 'cont' or cont == 'last' then + t[key] = obj + end + until cont ~= 'cont' + return pos, setmetatable (t, state.objectmeta) + end + + local Array = P"[" * g.Cmt (g.Carg(1) * g.Carg(2), parsearray) + local Object = P"{" * g.Cmt (g.Carg(1) * g.Carg(2), parseobject) + local Value = Space * (Array + Object + SimpleValue) + local ExpectedValue = Value + Space * Err "value expected" + local ExpectedKey = String + Err "key expected" + local End = P(-1) * g.Cc'end' + local ErrInvalid = Err "invalid JSON" + ArrayContent = (Value * Space * (P"," * g.Cc'cont' + P"]" * g.Cc'last'+ End + ErrInvalid) + g.Cc(nil) * (P"]" * g.Cc'empty' + End + ErrInvalid)) * g.Cp() + local Pair = g.Cg (Space * ExpectedKey * Space * (P":" + Err "colon expected") * ExpectedValue) + ObjectContent = (g.Cc(nil) * g.Cc(nil) * P"}" * g.Cc'empty' + End + (Pair * Space * (P"," * g.Cc'cont' + P"}" * g.Cc'last' + End + ErrInvalid) + ErrInvalid)) * g.Cp() + local DecodeValue = ExpectedValue * g.Cp () + + jsonlpeg.version = json.version + jsonlpeg.encode = json.encode + jsonlpeg.null = json.null + jsonlpeg.quotestring = json.quotestring + jsonlpeg.addnewline = json.addnewline + jsonlpeg.encodeexception = json.encodeexception + jsonlpeg.using_lpeg = true + + function jsonlpeg.decode (str, pos, nullval, ...) + local state = {} + state.objectmeta, state.arraymeta = optionalmetatables(...) + local obj, retpos = pegmatch (DecodeValue, str, pos, nullval, state) + if state.msg then + return nil, state.pos, state.msg + else + return obj, retpos + end + end + + -- cache result of this function: + json.use_lpeg = function () return jsonlpeg end + jsonlpeg.use_lpeg = json.use_lpeg + + return jsonlpeg +end + +if always_use_lpeg then + return json.use_lpeg() +end + +return json