From cd0329020a13e6f748b5eaecd51a50f83899e3e7 Mon Sep 17 00:00:00 2001 From: line0 Date: Sat, 23 May 2026 15:16:34 +0200 Subject: [PATCH 01/26] fix: add missing SemanticVersioning module --- macros/l0.DependencyControl.Toolbox.moon | 4 +- modules/DependencyControl.moon | 1 + modules/DependencyControl/ModuleLoader.moon | 342 +++++----- modules/DependencyControl/Record.moon | 628 +++++++++--------- .../DependencyControl/SemanticVersioning.moon | 60 ++ modules/DependencyControl/UpdateFeed.moon | 11 +- modules/DependencyControl/Updater.moon | 25 +- 7 files changed, 568 insertions(+), 503 deletions(-) create mode 100644 modules/DependencyControl/SemanticVersioning.moon diff --git a/macros/l0.DependencyControl.Toolbox.moon b/macros/l0.DependencyControl.Toolbox.moon index d66c5f6..3511e62 100644 --- a/macros/l0.DependencyControl.Toolbox.moon +++ b/macros/l0.DependencyControl.Toolbox.moon @@ -37,7 +37,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! @@ -96,7 +96,7 @@ install = -> tbl[namespace] or= {} for channel in *channels record = scriptData.data.channels[channel] - verNum = depRec\getVersionNumber record.version + verNum = DepCtrl.SemanticVersioning\toNumber record.version unless config.c[scriptType][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 } diff --git a/modules/DependencyControl.moon b/modules/DependencyControl.moon index b7a95c4..8e0132c 100644 --- a/modules/DependencyControl.moon +++ b/modules/DependencyControl.moon @@ -24,6 +24,7 @@ class DependencyControl extends Record @Updater = Updater @UnitTestSuite = UnitTestSuite @FileOps = FileOps + @SemanticVersioning = SemanticVersioning rec = DependencyControl{ diff --git a/modules/DependencyControl/ModuleLoader.moon b/modules/DependencyControl/ModuleLoader.moon index e1f6738..2b4971e 100644 --- a/modules/DependencyControl/ModuleLoader.moon +++ b/modules/DependencyControl/ModuleLoader.moon @@ -1,172 +1,174 @@ --- 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 +-- 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 + +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" + +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 = SemanticVersioning\toNumber 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 + 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 diff --git a/modules/DependencyControl/Record.moon b/modules/DependencyControl/Record.moon index 5293321..5aef6fb 100644 --- a/modules/DependencyControl/Record.moon +++ b/modules/DependencyControl/Record.moon @@ -1,315 +1,315 @@ -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 +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 = 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 + + 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 + + + -- 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 + + + 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 = SemanticVersioning\toNumber 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 diff --git a/modules/DependencyControl/SemanticVersioning.moon b/modules/DependencyControl/SemanticVersioning.moon new file mode 100644 index 0000000..f8b9fb9 --- /dev/null +++ b/modules/DependencyControl/SemanticVersioning.moon @@ -0,0 +1,60 @@ +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}} + + @toString = (version, precision = "patch") => + if type(version) == "string" + version = @toNumber 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 + + + @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 > 256 + 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 + + + @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 \ No newline at end of file diff --git a/modules/DependencyControl/UpdateFeed.moon b/modules/DependencyControl/UpdateFeed.moon index 7ec8035..60f539d 100644 --- a/modules/DependencyControl/UpdateFeed.moon +++ b/modules/DependencyControl/UpdateFeed.moon @@ -4,6 +4,7 @@ DownloadManager = require "DM.DownloadManager" DependencyControl = nil Logger = require "l0.DependencyControl.Logger" Common = require "l0.DependencyControl.Common" +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" defaultLogger = Logger fileBaseName: "DepCtrl.UpdateFeed" @@ -53,20 +54,20 @@ class ScriptUpdateRecord extends Common getChangelog: (versionRecord, minVer = 0) => return "" unless "table" == type @changelog - maxVer = DependencyControl\parseVersion @version - minVer = DependencyControl\parseVersion minVer + maxVer = SemanticVersioning\toNumber @version + minVer = SemanticVersioning\toNumber minVer changelog = {} for ver, entry in pairs @changelog - ver = DependencyControl\parseVersion ver - verStr = DependencyControl\getVersionString ver + 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, DependencyControl\getVersionString(@version), @released or ""} + 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 diff --git a/modules/DependencyControl/Updater.moon b/modules/DependencyControl/Updater.moon index 888a105..2480ba4 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/DependencyControl/Updater.moon @@ -7,6 +7,7 @@ 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 class UpdaterBase extends Common @@ -97,7 +98,7 @@ class UpdateTask extends UpdaterBase @logger = @updater.logger @triedFeeds = {} @status = nil - @targetVersion = DependencyControl\parseVersion targetVersion + @targetVersion = SemanticVersioning\toNumber targetVersion -- set UpdateFeed settings @feedConfig = { @@ -109,7 +110,7 @@ class UpdateTask extends UpdaterBase return nil, -2 unless @record\validateNamespace! set: (targetVersion, @addFeeds, @exhaustive, @channel, @optional) => - @targetVersion = DependencyControl\parseVersion targetVersion + @targetVersion = SemanticVersioning\toNumber targetVersion return @ checkFeed: (feedUrl) => @@ -161,7 +162,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 +230,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], @@ -379,20 +380,20 @@ class UpdateTask extends UpdaterBase @ref = ref else with @record - .name, .version, .virtual = @record.name, DependencyControl\parseVersion update.version + .name, .version, .virtual = @record.name, SemanticVersioning\toNumber update.version @record\writeConfig! @updated = true @logger\log msgs.performUpdate.updSuccess, @@terms.capitalize(@@terms.isInstall[wasVirtual]), @@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 + @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,7 +407,7 @@ 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 class Updater extends UpdaterBase msgs = { From f147a45ef374c5a80bc5a1c4653f0a5e4072c41c Mon Sep 17 00:00:00 2001 From: line0 Date: Sat, 23 May 2026 16:54:40 +0200 Subject: [PATCH 02/26] fix: broken script type handling in update feeds --- macros/l0.DependencyControl.Toolbox.moon | 21 ++++++++++++++------- modules/DependencyControl/UpdateFeed.moon | 17 +++++++++++++---- modules/DependencyControl/Updater.moon | 3 ++- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/macros/l0.DependencyControl.Toolbox.moon b/macros/l0.DependencyControl.Toolbox.moon index 3511e62..b757db5 100644 --- a/macros/l0.DependencyControl.Toolbox.moon +++ b/macros/l0.DependencyControl.Toolbox.moon @@ -11,7 +11,8 @@ 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'..." @@ -90,16 +91,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 = DepCtrl.SemanticVersioning\toNumber record.version - unless config.c[scriptType][namespace] or (tbl[namespace][channel] and verNum < tbl[namespace][channel].verNum) + 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 +127,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 diff --git a/modules/DependencyControl/UpdateFeed.moon b/modules/DependencyControl/UpdateFeed.moon index 60f539d..5d228db 100644 --- a/modules/DependencyControl/UpdateFeed.moon +++ b/modules/DependencyControl/UpdateFeed.moon @@ -106,6 +106,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." } } @@ -141,14 +142,13 @@ 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 - getKnownFeeds: => return {} unless @data return [url for _, url in pairs @data.knownFeeds] @@ -239,13 +239,22 @@ class UpdateFeed extends Common return @data 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 getMacro: (namespace, config, autoChannel) => - @getScript namespace, false, config, autoChannel + @getScript namespace, @@ScriptType.Automation, config, autoChannel getModule: (namespace, config, autoChannel) => - @getScript namespace, true, config, autoChannel \ No newline at end of file + @getScript namespace, @@ScriptType.Module, config, autoChannel \ No newline at end of file diff --git a/modules/DependencyControl/Updater.moon b/modules/DependencyControl/Updater.moon index 2480ba4..ccf5380 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/DependencyControl/Updater.moon @@ -122,8 +122,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 From bf665954cda2873055607749cbf5b5ef34395e08 Mon Sep 17 00:00:00 2001 From: line0 Date: Sat, 23 May 2026 18:00:23 +0200 Subject: [PATCH 03/26] fix: module loader running depctrl initializer hooks on already initialized modules --- modules/DependencyControl/ModuleLoader.moon | 344 ++++++++++---------- 1 file changed, 172 insertions(+), 172 deletions(-) diff --git a/modules/DependencyControl/ModuleLoader.moon b/modules/DependencyControl/ModuleLoader.moon index 2b4971e..fb5e9e6 100644 --- a/modules/DependencyControl/ModuleLoader.moon +++ b/modules/DependencyControl/ModuleLoader.moon @@ -1,174 +1,174 @@ --- 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 - -SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" - -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 +-- 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 + +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" + +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 = SemanticVersioning\toNumber 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 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 = 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 From f2e1fa86b657889ecec5047e0061f6217efc2c4e Mon Sep 17 00:00:00 2001 From: line0 Date: Sat, 23 May 2026 18:31:52 +0200 Subject: [PATCH 04/26] fix: update failing due to unsupported string index in capitalization function --- modules/DependencyControl/Common.moon | 2 +- modules/DependencyControl/Updater.moon | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/modules/DependencyControl/Common.moon b/modules/DependencyControl/Common.moon index 8de8024..33ffcb7 100644 --- a/modules/DependencyControl/Common.moon +++ b/modules/DependencyControl/Common.moon @@ -15,7 +15,7 @@ class DependencyControlCommon [false]: "update" } - capitalize: (str) -> str[1]\upper! .. str\sub 2 + capitalize: (str) -> (str\sub 1, 1)\upper! .. str\sub 2 } -- Common enums diff --git a/modules/DependencyControl/Updater.moon b/modules/DependencyControl/Updater.moon index ccf5380..45233ff 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/DependencyControl/Updater.moon @@ -381,15 +381,17 @@ class UpdateTask extends UpdaterBase @ref = ref else with @record - .name, .version, .virtual = @record.name, SemanticVersioning\toNumber 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, SemanticVersioning\toString @record.version - -- Diplay changelog + -- Display changelog @logger\log update\getChangelog @record, (SemanticVersioning\toNumber oldVer) + 1 @logger\log msgs.performUpdate.reloadNotice From 977f37a0b15f0e8658a4155287e9b25e001fab30 Mon Sep 17 00:00:00 2001 From: line0 Date: Sat, 23 May 2026 18:47:21 +0200 Subject: [PATCH 05/26] test: add first test --- DependencyControl.json | 10 ++++++++-- macros/l0.DependencyControl.Toolbox.moon | 3 +++ modules/DependencyControl/Tests.moon | 11 +++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 modules/DependencyControl/Tests.moon diff --git a/DependencyControl.json b/DependencyControl.json index 90690ce..5134a41 100644 --- a/DependencyControl.json +++ b/DependencyControl.json @@ -76,8 +76,8 @@ "fileBaseUrl": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{scriptName}", "channels": { "alpha": { - "version": "0.6.3", - "released": "2016-02-06", + "version": "0.6.4", + "released": "2026-05-23", "default": true, "files": [ { @@ -114,6 +114,12 @@ "name": "/Updater.moon", "url": "@{fileBaseUrl}@{fileName}", "sha1": "A4AE061724E68B2EFBB7495A477263E1746E228A" + }, + { + "name": ".moon", + "type": "test", + "url": "@{fileBaseUrl}/Tests.moon", + "sha1": "1ED8961CAFCADA7E4C04778227EECDC18E509B8D" } ], "requiredModules": [ diff --git a/macros/l0.DependencyControl.Toolbox.moon b/macros/l0.DependencyControl.Toolbox.moon index b757db5..836734f 100644 --- a/macros/l0.DependencyControl.Toolbox.moon +++ b/macros/l0.DependencyControl.Toolbox.moon @@ -220,6 +220,9 @@ 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}, diff --git a/modules/DependencyControl/Tests.moon b/modules/DependencyControl/Tests.moon new file mode 100644 index 0000000..af0ff3e --- /dev/null +++ b/modules/DependencyControl/Tests.moon @@ -0,0 +1,11 @@ +DependencyControl = require "l0.DependencyControl" + +DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> + { + 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" + } + } From 75b6c43591e1826be7c75882d10a9e13fce9881b Mon Sep 17 00:00:00 2001 From: line0 Date: Sat, 23 May 2026 18:50:02 +0200 Subject: [PATCH 06/26] ci: enforce LF + EOF newline for consistent hash calculation --- .gitattributes | 1 + .vscode/settings.json | 3 + macros/l0.DependencyControl.Toolbox.moon | 2 +- modules/DependencyControl.moon | 2 +- modules/DependencyControl/Common.moon | 84 ++--- modules/DependencyControl/ConfigHandler.moon | 2 +- modules/DependencyControl/FileOps.moon | 2 +- modules/DependencyControl/ModuleLoader.moon | 346 +++++++++--------- modules/DependencyControl/Record.moon | 2 +- .../DependencyControl/SemanticVersioning.moon | 2 +- modules/DependencyControl/UnitTestSuite.moon | 2 +- modules/DependencyControl/UpdateFeed.moon | 2 +- modules/DependencyControl/Updater.moon | 2 +- 13 files changed, 228 insertions(+), 224 deletions(-) create mode 100644 .gitattributes create mode 100644 .vscode/settings.json 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/macros/l0.DependencyControl.Toolbox.moon b/macros/l0.DependencyControl.Toolbox.moon index 836734f..0abd5a6 100644 --- a/macros/l0.DependencyControl.Toolbox.moon +++ b/macros/l0.DependencyControl.Toolbox.moon @@ -228,4 +228,4 @@ depRec\registerMacros{ {"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/DependencyControl.moon b/modules/DependencyControl.moon index 8e0132c..111848e 100644 --- a/modules/DependencyControl.moon +++ b/modules/DependencyControl.moon @@ -47,4 +47,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 33ffcb7..00d77d2 100644 --- a/modules/DependencyControl/Common.moon +++ b/modules/DependencyControl/Common.moon @@ -1,42 +1,42 @@ -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\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"} - } - } - - 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" + +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\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"} + } + } + + 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")} diff --git a/modules/DependencyControl/ConfigHandler.moon b/modules/DependencyControl/ConfigHandler.moon index 11cfacf..6bb9909 100644 --- a/modules/DependencyControl/ConfigHandler.moon +++ b/modules/DependencyControl/ConfigHandler.moon @@ -327,4 +327,4 @@ Reload your automation scripts to generate a new configuration file.]] @userConfig[k] = isTable and @deepCopy(v) or v changesMade = true - return changesMade \ No newline at end of file + return changesMade diff --git a/modules/DependencyControl/FileOps.moon b/modules/DependencyControl/FileOps.moon index 1369a60..75defed 100644 --- a/modules/DependencyControl/FileOps.moon +++ b/modules/DependencyControl/FileOps.moon @@ -304,4 +304,4 @@ class FileOps path = table.concat({dev, dir, file and pathMatch.sep, file}) - return path, dev, dir, file \ No newline at end of file + return path, dev, dir, file diff --git a/modules/DependencyControl/ModuleLoader.moon b/modules/DependencyControl/ModuleLoader.moon index fb5e9e6..de8a0e5 100644 --- a/modules/DependencyControl/ModuleLoader.moon +++ b/modules/DependencyControl/ModuleLoader.moon @@ -1,174 +1,174 @@ --- 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 - -SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" - -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 +-- 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 + +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" + +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 = SemanticVersioning\toNumber 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 = 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 + 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 = 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 diff --git a/modules/DependencyControl/Record.moon b/modules/DependencyControl/Record.moon index 5aef6fb..fc8740b 100644 --- a/modules/DependencyControl/Record.moon +++ b/modules/DependencyControl/Record.moon @@ -312,4 +312,4 @@ class Record extends Common -- 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 + return FileOps.remove toRemove, true, true diff --git a/modules/DependencyControl/SemanticVersioning.moon b/modules/DependencyControl/SemanticVersioning.moon index f8b9fb9..09c50f1 100644 --- a/modules/DependencyControl/SemanticVersioning.moon +++ b/modules/DependencyControl/SemanticVersioning.moon @@ -57,4 +57,4 @@ class SemanticVersioning break if precision == part[1] b = bit.band b, mask - return a >= b, b \ No newline at end of file + return a >= b, b diff --git a/modules/DependencyControl/UnitTestSuite.moon b/modules/DependencyControl/UnitTestSuite.moon index 64b9301..7157fdb 100644 --- a/modules/DependencyControl/UnitTestSuite.moon +++ b/modules/DependencyControl/UnitTestSuite.moon @@ -840,4 +840,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 5d228db..a196e69 100644 --- a/modules/DependencyControl/UpdateFeed.moon +++ b/modules/DependencyControl/UpdateFeed.moon @@ -257,4 +257,4 @@ class UpdateFeed extends Common @getScript namespace, @@ScriptType.Automation, config, autoChannel getModule: (namespace, config, autoChannel) => - @getScript namespace, @@ScriptType.Module, 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 45233ff..f9ee8e1 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/DependencyControl/Updater.moon @@ -525,4 +525,4 @@ class Updater extends UpdaterBase return false unless @hasLock @hasLock = false @config.c.updaterRunning = false - @config\write! \ No newline at end of file + @config\write! From 7f2530afbc9ac2fde48cd47246af4a9e9050c91c Mon Sep 17 00:00:00 2001 From: line0 Date: Sat, 23 May 2026 20:55:01 +0200 Subject: [PATCH 07/26] build: bump version; update feed --- DependencyControl.json | 66 ++++++++++++++++++------ macros/l0.DependencyControl.Toolbox.moon | 2 +- modules/DependencyControl.moon | 2 +- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/DependencyControl.json b/DependencyControl.json index 5134a41..6fe2d04 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,64 @@ "fileBaseUrl": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{scriptName}", "channels": { "alpha": { - "version": "0.6.4", + "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", @@ -147,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." ], diff --git a/macros/l0.DependencyControl.Toolbox.moon b/macros/l0.DependencyControl.Toolbox.moon index 0abd5a6..ce94b73 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" diff --git a/modules/DependencyControl.moon b/modules/DependencyControl.moon index 111848e..09f6696 100644 --- a/modules/DependencyControl.moon +++ b/modules/DependencyControl.moon @@ -29,7 +29,7 @@ class DependencyControl extends Record 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", From ff8495a5d1bb70d3c1f03af51d2b5b1523e77dac Mon Sep 17 00:00:00 2001 From: line0 Date: Sat, 23 May 2026 21:18:53 +0200 Subject: [PATCH 08/26] fix: typo in ModuleLoader breaking optional module presence checks --- modules/DependencyControl/ModuleLoader.moon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/DependencyControl/ModuleLoader.moon b/modules/DependencyControl/ModuleLoader.moon index de8a0e5..e9ccba1 100644 --- a/modules/DependencyControl/ModuleLoader.moon +++ b/modules/DependencyControl/ModuleLoader.moon @@ -164,7 +164,7 @@ class ModuleLoader @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, + 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 From d0cbeee4bdc77719c41e679b6924851872f83a08 Mon Sep 17 00:00:00 2001 From: line0 Date: Sat, 23 May 2026 22:50:08 +0200 Subject: [PATCH 09/26] refactor: tighten version number validation in updater --- macros/l0.DependencyControl.Toolbox.moon | 20 ++++++++++-------- modules/DependencyControl/Updater.moon | 26 +++++++++++++----------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/macros/l0.DependencyControl.Toolbox.moon b/macros/l0.DependencyControl.Toolbox.moon index ce94b73..0fe60ea 100644 --- a/macros/l0.DependencyControl.Toolbox.moon +++ b/macros/l0.DependencyControl.Toolbox.moon @@ -78,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 @@ -139,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) -> @@ -190,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" diff --git a/modules/DependencyControl/Updater.moon b/modules/DependencyControl/Updater.moon index f9ee8e1..c3c4c39 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/DependencyControl/Updater.moon @@ -22,6 +22,7 @@ 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" @@ -91,14 +92,15 @@ class UpdateTask extends UpdaterBase } } - new: (@record, targetVersion = 0, @addFeeds, @exhaustive, @channel, @optional, @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 = SemanticVersioning\toNumber targetVersion + @targetVersion = targetVersionNumber -- set UpdateFeed settings @feedConfig = { @@ -109,10 +111,6 @@ 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 = SemanticVersioning\toNumber targetVersion - return @ - checkFeed: (feedUrl) => -- get feed contents feed = UpdateFeed feedUrl, false, nil, @feedConfig, @logger @@ -439,18 +437,22 @@ class Updater extends UpdaterBase depRec[k] = v for k, v in pairs record record = DependencyControl depRec + targetVersionNumber, err = SemanticVersioning\toNumber targetVersion + if (err) then return nil, -8, err + 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, @ + 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, err + return task, code 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 From d16fd6416f55401de6d029a63342b8bb53770de8 Mon Sep 17 00:00:00 2001 From: line0 Date: Sat, 23 May 2026 23:34:20 +0200 Subject: [PATCH 10/26] fix: broken version number display in version errors --- modules/DependencyControl/ModuleLoader.moon | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/DependencyControl/ModuleLoader.moon b/modules/DependencyControl/ModuleLoader.moon index e9ccba1..be57a8c 100644 --- a/modules/DependencyControl/ModuleLoader.moon +++ b/modules/DependencyControl/ModuleLoader.moon @@ -26,7 +26,8 @@ class ModuleLoader @formatVersionErrorTemplate = (name, reqVersion, url, reason, ref) => url = url and ": #{url}" or "" if ref - version = SemanticVersioning\toNumber ref.version + -- 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 "" From 167d427215d9cc4a668edb047dcd055b7c086593 Mon Sep 17 00:00:00 2001 From: line0 Date: Sat, 23 May 2026 23:34:51 +0200 Subject: [PATCH 11/26] fix: typos --- macros/l0.DependencyControl.Toolbox.moon | 2 +- modules/DependencyControl/ModuleLoader.moon | 4 ++-- modules/DependencyControl/Updater.moon | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/macros/l0.DependencyControl.Toolbox.moon b/macros/l0.DependencyControl.Toolbox.moon index 0fe60ea..7e674ef 100644 --- a/macros/l0.DependencyControl.Toolbox.moon +++ b/macros/l0.DependencyControl.Toolbox.moon @@ -16,7 +16,7 @@ msgs = { } 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" } diff --git a/modules/DependencyControl/ModuleLoader.moon b/modules/DependencyControl/ModuleLoader.moon index be57a8c..d2db654 100644 --- a/modules/DependencyControl/ModuleLoader.moon +++ b/modules/DependencyControl/ModuleLoader.moon @@ -1,6 +1,6 @@ -- 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 +-- 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" diff --git a/modules/DependencyControl/Updater.moon b/modules/DependencyControl/Updater.moon index c3c4c39..c336ee7 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/DependencyControl/Updater.moon @@ -29,9 +29,9 @@ class UpdaterBase extends Common [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"} @@ -87,8 +87,8 @@ 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." } } @@ -446,7 +446,7 @@ class Updater extends UpdaterBase .addFeeds, .exhaustive, .channel, .optional = addFeeds, exhaustive, channel, optional task, code = UpdateTask record, targetVersionNumber, addFeeds, exhaustive, channel, optional, @ - @tasks[record.scriptType][record.namespace] = task + @tasks[record.scriptType][record.namespace] = task return task, code require: (record, ...) => From a1fc3f89fe300f5577e9b134f6aadccc7da3269d Mon Sep 17 00:00:00 2001 From: line0 Date: Sun, 24 May 2026 00:03:37 +0200 Subject: [PATCH 12/26] fix: semver parsers allowing invalid values number segments over 255 and non-dot separate values --- modules/DependencyControl/SemanticVersioning.moon | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/DependencyControl/SemanticVersioning.moon b/modules/DependencyControl/SemanticVersioning.moon index 09c50f1..88f4bd8 100644 --- a/modules/DependencyControl/SemanticVersioning.moon +++ b/modules/DependencyControl/SemanticVersioning.moon @@ -3,7 +3,7 @@ class SemanticVersioning 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." + overflow: "Error: %s version must be an integer <= 255, got %s." } } @@ -26,14 +26,14 @@ class SemanticVersioning when "number" then math.max value, 0 when "nil" then 0 when "string" - matches = {value\match "^(%d+).(%d+).(%d+)$"} + 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 > 256 + if type(value) != "number" or value > 255 return false, msgs.toNumber.overflow\format part[1], tostring value version += bit.lshift value, part[2] From 5bc3f451109632d98108b34e71c66e9d71b957c0 Mon Sep 17 00:00:00 2001 From: line0 Date: Sun, 24 May 2026 00:08:00 +0200 Subject: [PATCH 13/26] fix: semantic version toString roundtrip exception when invalid semver string is passed --- modules/DependencyControl/SemanticVersioning.moon | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/DependencyControl/SemanticVersioning.moon b/modules/DependencyControl/SemanticVersioning.moon index 88f4bd8..fd410bc 100644 --- a/modules/DependencyControl/SemanticVersioning.moon +++ b/modules/DependencyControl/SemanticVersioning.moon @@ -11,7 +11,8 @@ class SemanticVersioning @toString = (version, precision = "patch") => if type(version) == "string" - version = @toNumber version + version, err = @toNumber version + return nil, err unless version parts = {0, 0, 0} for i, part in ipairs semParts From c08a79cc4bcd2aaa78becefd7516b7230cbb82e7 Mon Sep 17 00:00:00 2001 From: line0 Date: Sun, 24 May 2026 08:14:46 +0200 Subject: [PATCH 14/26] fix: update errors missing information about whether or not a record is virtual --- modules/DependencyControl/Updater.moon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/DependencyControl/Updater.moon b/modules/DependencyControl/Updater.moon index c336ee7..c2a8248 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/DependencyControl/Updater.moon @@ -148,7 +148,7 @@ class UpdateTask extends UpdaterBase 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 From 80ead7c18abb0646bbb099ee9f84799f8e3c6db8 Mon Sep 17 00:00:00 2001 From: line0 Date: Sun, 24 May 2026 08:25:50 +0200 Subject: [PATCH 15/26] fix: broken skip lists in module loader nothing inside DepCtrl is supplying this parameter, so the only effect this bug would have had, is removing the default guard against modules trying to load themselves --- modules/DependencyControl/ModuleLoader.moon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/DependencyControl/ModuleLoader.moon b/modules/DependencyControl/ModuleLoader.moon index d2db654..689eee6 100644 --- a/modules/DependencyControl/ModuleLoader.moon +++ b/modules/DependencyControl/ModuleLoader.moon @@ -96,7 +96,7 @@ class ModuleLoader @loadModules = (modules, addFeeds = {@feed}, skip = @moduleName and {[@moduleName]: true} or {}) => for mdl in *modules - continue if skip[mdl] + continue if skip[mdl.moduleName] with mdl ._ref, ._updated, ._missing, ._outdated, ._reason, ._error = nil From c86e07ece433cc9e0a8b547bda1066d95b69e71a Mon Sep 17 00:00:00 2001 From: line0 Date: Tue, 26 May 2026 22:37:19 +0200 Subject: [PATCH 16/26] feat: port Logger improvements from sqlite branch - assert now passes through all varargs on success (allows chaining) - new assertNotNil method - fails only on nil, unlike assert which also fails on false - dump/dumpToString methods accept a maxDepth to cap recursive table output - new static Logger.describeType method - returns Lua type name but renders MoonScript class instances as "ClassName object" - fix: guard log/format calls against non-string message templates --- modules/DependencyControl/Logger.moon | 36 +++++++++++++++++++++------ 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/modules/DependencyControl/Logger.moon b/modules/DependencyControl/Logger.moon index 17a5abf..be238dc 100644 --- a/modules/DependencyControl/Logger.moon +++ b/modules/DependencyControl/Logger.moon @@ -51,7 +51,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 +73,7 @@ class Logger msg = table.concat msg, "\n" if 0 < select "#", ... - msg = msg\format ... + msg = (tostring msg)\format ... return msg unless indent>0 @@ -99,7 +99,12 @@ class Logger assert: (cond, ...) => if not cond @log 1, ... - else return cond + else return cond, ... + + assertNotNil: (cond, ...) => + if cond == nil + @log 1, ... + else return cond, ... progress: (progress=false, msg = "", ...) => if @progressStep and not progress @@ -115,10 +120,10 @@ 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 + dump: ( item, ignore, level = @defaultLevel, maxDepth ) => + @log level, @dumpToString item, ignore, maxDepth - dumpToString: ( item, ignore ) => + dumpToString: ( item, ignore, maxDepth ) => if "table" != type item return tostring item @@ -126,7 +131,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 +149,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 +195,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 From 5a582f02fbae62e6f2314ab3fb3e311590be732a Mon Sep 17 00:00:00 2001 From: line0 Date: Tue, 26 May 2026 22:38:17 +0200 Subject: [PATCH 17/26] fix: nil dereference crash in feed template variable expansion --- modules/DependencyControl/UpdateFeed.moon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/DependencyControl/UpdateFeed.moon b/modules/DependencyControl/UpdateFeed.moon index a196e69..5f1c7ed 100644 --- a/modules/DependencyControl/UpdateFeed.moon +++ b/modules/DependencyControl/UpdateFeed.moon @@ -192,7 +192,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} From 43830184925acfd1460d85846546b4e457749df9 Mon Sep 17 00:00:00 2001 From: line0 Date: Tue, 26 May 2026 22:40:55 +0200 Subject: [PATCH 18/26] refactor: add basic type annotations --- modules/DependencyControl/Common.moon | 2 + modules/DependencyControl/ConfigHandler.moon | 60 +++++++++++++++++ modules/DependencyControl/FileOps.moon | 31 +++++++++ modules/DependencyControl/Logger.moon | 28 ++++++++ modules/DependencyControl/ModuleLoader.moon | 12 ++++ modules/DependencyControl/Record.moon | 63 +++++++++++++++++- .../DependencyControl/SemanticVersioning.moon | 17 +++++ modules/DependencyControl/UnitTestSuite.moon | 1 + modules/DependencyControl/UpdateFeed.moon | 59 +++++++++++++++++ modules/DependencyControl/Updater.moon | 65 +++++++++++++++++++ 10 files changed, 336 insertions(+), 2 deletions(-) diff --git a/modules/DependencyControl/Common.moon b/modules/DependencyControl/Common.moon index 00d77d2..5fd4cb0 100644 --- a/modules/DependencyControl/Common.moon +++ b/modules/DependencyControl/Common.moon @@ -1,5 +1,7 @@ ffi = require "ffi" +--- Shared constants, enums, and terminology used across DependencyControl modules. +-- @class DependencyControlCommon class DependencyControlCommon -- Some terms are shared across components @platform = "#{ffi.os}-#{ffi.arch}" diff --git a/modules/DependencyControl/ConfigHandler.moon b/modules/DependencyControl/ConfigHandler.moon index 6bb9909..7d42a43 100644 --- a/modules/DependencyControl/ConfigHandler.moon +++ b/modules/DependencyControl/ConfigHandler.moon @@ -6,6 +6,8 @@ mutex = require "BM.BadMutex" fileOps = require "l0.DependencyControl.FileOps" Logger = require "l0.DependencyControl.Logger" +--- JSON-backed configuration manager with cooperative cross-script locking. +-- @class ConfigHandler class ConfigHandler @handlers = {} errors = { @@ -35,6 +37,12 @@ Reload your automation scripts to generate a new configuration file.]] -- waitingLockTimeout: "Timeout was reached after %d seconds, force-releasing lock..." } + --- Creates a configuration handler for a JSON file and optional nested section. + -- @param file string + -- @param[opt] defaults table + -- @param[opt] section string|string[] + -- @param[opt] noLoad boolean + -- @param[opt] logger Logger new: (@file, defaults, @section, noLoad, @logger = Logger fileBaseName: @@__name) => @section = {@section} if "table" != type @section @defaults = defaults and util.deep_copy(defaults) or {} @@ -101,6 +109,10 @@ Reload your automation scripts to generate a new configuration file.]] recurse @defaults @load! unless noLoad + --- Registers and validates the target config file path for this handler. + -- @param path string + -- @return boolean|nil + -- @return string|nil err setFile: (path) => return false unless path if @@handlers[path] @@ -111,6 +123,8 @@ Reload your automation scripts to generate a new configuration file.]] @file = path return true + --- Unregisters this handler from the shared file-handler registry. + -- @return boolean unsetFile: => handlers = @@handlers[@file] if handlers and #handlers>1 @@ -119,6 +133,12 @@ Reload your automation scripts to generate a new configuration file.]] @file = nil return true + --- Reads and decodes a JSON config file. + -- @param[opt] file string + -- @param[opt=true] useLock boolean + -- @param[opt] waitLockTime number + -- @return table|boolean|nil + -- @return string|nil err readFile: (file = @file, useLock = true, waitLockTime) => if useLock time, err = @getLock waitLockTime @@ -163,6 +183,9 @@ Reload your automation scripts to generate a new configuration file.]] return result + --- Loads this handler's configured section into the local user configuration table. + -- @return boolean + -- @return string|nil err load: => return false, errors.noFile unless @file @@ -183,6 +206,10 @@ Reload your automation scripts to generate a new configuration file.]] @userConfig[k] = v for k,v in pairs config return sectionExists + --- Merges this handler's section into an in-memory root config table. + -- @param config table + -- @return table|boolean + -- @return string|nil err mergeSection: (config) => --@logger\trace traceMsgs.mergeSectionStart, @logger\dumpToString(@section), -- @logger\dumpToString config @@ -210,10 +237,20 @@ Reload your automation scripts to generate a new configuration file.]] -- @logger\trace traceMsgs.mergeSectionResult, @logger\dumpToString config return config + --- Deletes this handler's section from disk by clearing user config and writing. + -- @param[opt] concertWrite boolean + -- @param[opt] waitLockTime number + -- @return boolean + -- @return string|nil err delete: (concertWrite, waitLockTime) => @userConfig = nil return @write concertWrite, waitLockTime + --- Writes current config state to disk, optionally coordinating all handlers sharing the same file. + -- @param[opt] concertWrite boolean + -- @param[opt] waitLockTime number + -- @return boolean + -- @return string|nil err write: (concertWrite, waitLockTime) => return false, errors.noFile unless @file @@ -261,6 +298,11 @@ Reload your automation scripts to generate a new configuration file.]] return true + --- Acquires the global config mutex lock. + -- @param[opt=5000] waitTimeout number + -- @param[opt=50] checkInterval number + -- @return number|boolean timePassedOrFalse + -- @return string|nil err getLock: (waitTimeout = 5000, checkInterval = 50) => return 0 if @hasLock success = mutex.tryLock! @@ -290,9 +332,18 @@ Reload your automation scripts to generate a new configuration file.]] --return waitTimeout return false, errors.lockTimeout + --- Creates a child handler bound to a subsection of the same config file. + -- @param section string|string[] + -- @param[opt] defaults table + -- @param[opt] noLoad boolean + -- @return ConfigHandler getSectionHandler: (section, defaults, noLoad) => return @@ @file, defaults, section, noLoad, @logger + --- Releases the global config mutex lock. + -- @param[opt] force boolean + -- @return boolean + -- @return string|nil err releaseLock: (force) => if @hasLock or force @hasLock = false @@ -300,6 +351,9 @@ Reload your automation scripts to generate a new configuration file.]] return true return false, errors.noLock + --- Deep-copies a table while skipping private keys prefixed with "_". + -- @param tbl table + -- @return table -- copied from Aegisub util.moon, adjusted to skip private keys deepCopy: (tbl) => seen = {} @@ -310,6 +364,12 @@ Reload your automation scripts to generate a new configuration file.]] {k, copy(v) for k, v in pairs val when type(k) != "string" or k\sub(1,1) != "_"} copy tbl + --- Imports values into this handler's user configuration. + -- @param[opt] tbl table|ConfigHandler + -- @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 diff --git a/modules/DependencyControl/FileOps.moon b/modules/DependencyControl/FileOps.moon index 75defed..ef15e15 100644 --- a/modules/DependencyControl/FileOps.moon +++ b/modules/DependencyControl/FileOps.moon @@ -5,6 +5,8 @@ lfs = require "lfs" Logger = require "l0.DependencyControl.Logger" local ConfigHandler +--- Filesystem utility helpers used by DependencyControl. +-- @class FileOps class FileOps msgs = { generic: { @@ -74,6 +76,13 @@ class FileOps {toRemove: {}}, nil, noLoad, FileOps.logger return FileOps.config + --- Removes one or more files/directories and optionally reschedules failed removals. + -- @param paths string|string[] + -- @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, {} @@ -108,6 +117,9 @@ class FileOps config\write! if configLoaded return overallSuccess, details, firstErr + --- Replays removals previously scheduled by @{FileOps:remove}. + -- @param[opt] configDir string + -- @return boolean runScheduledRemoval: (configDir) -> config = createConfig false, configDir paths = [path for path, _ in pairs config.c.toRemove] @@ -118,6 +130,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" @@ -164,6 +181,12 @@ class FileOps return false, msgs.copy.genericError\format sourceFullPath, targetFullPath, msg + --- 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" @@ -269,6 +292,14 @@ class FileOps return attr, fullPath, dev, dir, file + --- Validates and normalizes an absolute filesystem path. + -- @param path string + -- @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" return nil, msgs.validateFullPath.badType\format type(path) diff --git a/modules/DependencyControl/Logger.moon b/modules/DependencyControl/Logger.moon index be238dc..fac7c4b 100644 --- a/modules/DependencyControl/Logger.moon +++ b/modules/DependencyControl/Logger.moon @@ -1,6 +1,8 @@ PreciseTimer = require "PT.PreciseTimer" 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 @@ -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 == "" @@ -96,11 +106,19 @@ 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, ... + --- 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, ... @@ -120,9 +138,19 @@ class Logger @progressStep = step -- taken from https://github.com/TypesettingCartel/Aegisub-Motion/blob/master/src/Log.moon + --- 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 diff --git a/modules/DependencyControl/ModuleLoader.moon b/modules/DependencyControl/ModuleLoader.moon index 689eee6..16db7a9 100644 --- a/modules/DependencyControl/ModuleLoader.moon +++ b/modules/DependencyControl/ModuleLoader.moon @@ -4,6 +4,8 @@ SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" +--- Internal module loading helpers for DependencyControl-managed module dependencies. +-- @class ModuleLoader class ModuleLoader msgs = { checkOptionalModules: { @@ -94,6 +96,12 @@ class ModuleLoader 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] @@ -163,6 +171,10 @@ class ModuleLoader 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, diff --git a/modules/DependencyControl/Record.moon b/modules/DependencyControl/Record.moon index fc8740b..b2d8b28 100644 --- a/modules/DependencyControl/Record.moon +++ b/modules/DependencyControl/Record.moon @@ -10,6 +10,8 @@ Updater = require "l0.DependencyControl.Updater" ModuleLoader = require "l0.DependencyControl.ModuleLoader" SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" +--- DependencyControl record representing one managed or unmanaged script/module. +-- @class Record class Record extends Common namespaceValidation = re.compile "^(?:[-\\w]+\\.)+[-\\w]+$" @@ -60,6 +62,8 @@ class Record extends Common FileOps.runScheduledRemoval @configDir + --- Creates a DependencyControl record from explicit arguments and/or script globals. + -- @param args table new: (args) => init Record unless @@logger @@ -135,13 +139,16 @@ class Record extends Common checkOptionalModules: ModuleLoader.checkOptionalModules - -- loads the DependencyControl global configuration + --- Loads global DependencyControl configuration. + -- @return ConfigHandler @loadConfig = => if @config @config\load! else @config = ConfigHandler @depConf.file, @depConf.globalDefaults, {"config"}, nil, @logger - -- loads the script configuration + --- 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= ConfigHandler not @virtual and @@depConf.file, {}, @@ -172,6 +179,8 @@ class Record extends Common 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 @@ -189,12 +198,22 @@ class Record extends Common @getVersionString = SemanticVersioning.toString + --- Resolves this record's external config file path. + -- @return string getConfigFileName: () => return aegisub.decode_path "#{@@configDir}/#{@configFile}" + --- Creates a ConfigHandler for this record's script-specific config file. + -- @param[opt] defaults table + -- @param[opt] section string|string[] + -- @param[opt] noLoad boolean + -- @return ConfigHandler getConfigHandler: (defaults, section, noLoad) => return ConfigHandler @getConfigFileName!, defaults, section, 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 @@ -203,18 +222,30 @@ class Record extends Common 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 ConfigHandler|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! @@ -225,6 +256,8 @@ class Record extends Common @@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}" @@ -239,12 +272,23 @@ class Record extends Common @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" @@ -266,6 +310,9 @@ class Record extends Common 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 @@ -273,6 +320,10 @@ class Record extends Common 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 @@ -280,9 +331,17 @@ class Record extends Common return version else return nil, err + --- Validates a dependency namespace according to DependencyControl rules. + -- @param[opt] namespace string + -- @param[opt] isVirtual boolean + -- @return boolean validateNamespace: (namespace = @namespace, isVirtual = @virtual) => return isVirtual or namespaceValidation\match @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", diff --git a/modules/DependencyControl/SemanticVersioning.moon b/modules/DependencyControl/SemanticVersioning.moon index fd410bc..1d4d765 100644 --- a/modules/DependencyControl/SemanticVersioning.moon +++ b/modules/DependencyControl/SemanticVersioning.moon @@ -1,3 +1,5 @@ +--- Semantic versioning utilities. +-- @class SemanticVersioning class SemanticVersioning msgs = { toNumber: { @@ -9,6 +11,11 @@ class SemanticVersioning 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 @@ -22,6 +29,10 @@ class SemanticVersioning 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 @@ -43,6 +54,12 @@ class SemanticVersioning 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 diff --git a/modules/DependencyControl/UnitTestSuite.moon b/modules/DependencyControl/UnitTestSuite.moon index 7157fdb..9d4eb3a 100644 --- a/modules/DependencyControl/UnitTestSuite.moon +++ b/modules/DependencyControl/UnitTestSuite.moon @@ -731,6 +731,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: { diff --git a/modules/DependencyControl/UpdateFeed.moon b/modules/DependencyControl/UpdateFeed.moon index 5f1c7ed..1d2fe55 100644 --- a/modules/DependencyControl/UpdateFeed.moon +++ b/modules/DependencyControl/UpdateFeed.moon @@ -8,6 +8,8 @@ SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" defaultLogger = Logger fileBaseName: "DepCtrl.UpdateFeed" +--- Feed-specific update information for a single script in a selected channel. +-- @class ScriptUpdateRecord class ScriptUpdateRecord extends Common msgs = { errors: { @@ -20,6 +22,13 @@ class ScriptUpdateRecord extends Common } } + --- Creates an update record for a single script entry in a feed. + -- @param namespace string + -- @param data table + -- @param[opt] config table + -- @param scriptType number + -- @param[opt=true] autoChannel boolean + -- @param[opt] logger Logger new: (@namespace, @data, @config = {c:{}}, scriptType, autoChannel = true, @logger = defaultLogger) => DependencyControl or= require "l0.DependencyControl" @moduleName = scriptType == @@ScriptType.Module and @namespace @@ -27,6 +36,9 @@ class ScriptUpdateRecord extends Common @setChannel! if autoChannel + --- Returns all available channel names for this script and the default channel. + -- @return string[] channels + -- @return string|nil defaultChannel getChannels: => channels, default = {} for name, channel in pairs @data.channels @@ -36,6 +48,10 @@ class ScriptUpdateRecord extends Common return channels, default + --- Selects the active update channel and exposes channel fields on this record. + -- @param[opt] channelName string + -- @return boolean + -- @return string activeChannel setChannel: (channelName = @config.c.activeChannel) => with @config.c .channels, default = @getChannels! @@ -48,10 +64,17 @@ class ScriptUpdateRecord extends Common @files = @files and [file for file in *@files when not file.platform or file.platform == @@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})[@@platform], @@platform + --- Formats changelog entries between the current and a minimum version as a string. + -- @param versionRecord any + -- @param[opt=0] minVer number|string + -- @return string changelog getChangelog: (versionRecord, minVer = 0) => return "" unless "table" == type @changelog maxVer = SemanticVersioning\toNumber @version @@ -76,6 +99,8 @@ class ScriptUpdateRecord extends Common return table.concat msg, "\n" + --- Downloaded and expanded update feed data source. + -- @class UpdateFeed class UpdateFeed extends Common templateData = { maxDepth: 7, @@ -132,6 +157,12 @@ 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" @@ -149,11 +180,17 @@ class UpdateFeed extends Common elseif autoFetch @fetch! + --- 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 @@ -183,6 +220,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} @@ -238,6 +278,13 @@ 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 @@ -253,8 +300,20 @@ class UpdateFeed extends Common 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, @@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, @@ScriptType.Module, config, autoChannel diff --git a/modules/DependencyControl/Updater.moon b/modules/DependencyControl/Updater.moon index c2a8248..c2746a2 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/DependencyControl/Updater.moon @@ -10,6 +10,8 @@ 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 = { @@ -37,6 +39,13 @@ class UpdaterBase extends Common 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 @@ -48,6 +57,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 = { @@ -92,6 +103,14 @@ class UpdateTask extends UpdaterBase } } + --- 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." @@ -111,6 +130,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! + --- 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 @@ -147,6 +171,11 @@ 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 = @record.virtual) -> if code < 0 @@ -246,6 +275,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 @@ -410,6 +443,8 @@ class UpdateTask extends UpdaterBase @logger\log msgs.refreshRecord.otherUpdate, @@terms.scriptType.singular[.scriptType], .name, SemanticVersioning\toString @record.version +--- Coordinates background update checks and update task lifecycle. +-- @class Updater class Updater extends UpdaterBase msgs = { getLock: { @@ -427,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 @@ -449,6 +498,12 @@ class Updater extends UpdaterBase @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 @@ -465,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 @@ -486,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 @@ -523,6 +586,8 @@ class Updater extends UpdaterBase return true + --- Releases the global updater lock. + -- @return boolean releaseLock: => return false unless @hasLock @hasLock = false From d55348e1784465e0b68b3b3ca4c375c2bdcef786 Mon Sep 17 00:00:00 2001 From: line0 Date: Tue, 26 May 2026 22:53:53 +0200 Subject: [PATCH 19/26] refactor: port over FileOps improvements from sqlite branch - feat: add FileOps.readFile: opens a path, checks it's a regular file, reads and returns the full contents - feat: add FileOps.getNamespacedPath - converts a base directory + namespace string (e.g. "l0.Foo.Bar") into a nested filesystem path (basePath/l0/Foo/Bar.ext), with full validation. - fix: FileOps.remove/runScheduledRemoval handle missing config file gracefully - previously would crash if the ConfigHandler couldn't be created during a rescheduled deletion --- modules/DependencyControl/Common.moon | 17 +++++ modules/DependencyControl/FileOps.moon | 87 +++++++++++++++++++++++--- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/modules/DependencyControl/Common.moon b/modules/DependencyControl/Common.moon index 5fd4cb0..52a059a 100644 --- a/modules/DependencyControl/Common.moon +++ b/modules/DependencyControl/Common.moon @@ -1,8 +1,14 @@ ffi = require "ffi" +re = require "aegisub.re" --- 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}" @@ -35,6 +41,17 @@ class DependencyControlCommon } } + namespaceValidation = re.compile "^(?:[-\\w]+\\.)+[-\\w]+$" + + --- Validates a DependencyControl namespace string. + -- @param namespace string + -- @return boolean|nil + -- @return string|nil err + @validateNamespace = (namespace) -> + return if namespaceValidation\match namespace + true + else false, msgs.validateNamespace.badNamespace\format namespace + automationDir: { aegisub.decode_path("?user/automation/autoload"), aegisub.decode_path("?user/automation/include") diff --git a/modules/DependencyControl/FileOps.moon b/modules/DependencyControl/FileOps.moon index ef15e15..de87a77 100644 --- a/modules/DependencyControl/FileOps.moon +++ b/modules/DependencyControl/FileOps.moon @@ -3,6 +3,7 @@ re = require "aegisub.re" lfs = require "lfs" Logger = require "l0.DependencyControl.Logger" +Common = require "l0.DependencyControl.Common" local ConfigHandler --- Filesystem utility helpers used by DependencyControl. @@ -18,6 +19,9 @@ class FileOps noAttribute: "Can't find attriubte with name '%s'." } + createConfig: { + handlerFailed: "Couldn't create ConfigHandler for the FileOps configuration file: %s" + } mkdir: { createError: "Error creating directory: %s." otherExists: "Couldn't create directory because a %s of the same name is already present." @@ -42,12 +46,27 @@ 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." + } + 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)." @@ -72,8 +91,10 @@ class FileOps 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 + unless FileOps.config + FileOps.config = ConfigHandler "#{FileOps.configDir}/l0.#{FileOps.__name}.json", + {toRemove: {}}, nil, noLoad, FileOps.logger + return nil, msgs.createConfig.handlerFailed\format "constructor returned nil" unless FileOps.config return FileOps.config --- Removes one or more files/directories and optionally reschedules failed removals. @@ -84,8 +105,7 @@ class FileOps -- @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 @@ -102,8 +122,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} @@ -120,8 +148,13 @@ class FileOps --- 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 @@ -239,6 +272,25 @@ class FileOps return true + --- Reads and returns the full contents of a file. + -- @param path string + -- @return string|nil data + -- @return string|nil err + 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 + rmdir: (path, recurse = true) -> return nil, msgs.rmdir.emptyPath if path == "" mode, path = FileOps.attributes path, "mode" @@ -336,3 +388,24 @@ class FileOps 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 + -- @param namespace string + -- @param ext string + -- @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 + + res, msg = FileOps.validateFullPath basePath + return nil, msgs.getNamespacedPath.badBasePath\format basePath, msg unless res + + path = "#{basePath}/#{nested and namespace\gsub("%.", "/") or namespace}#{ext}" + path, msg = FileOps.validateFullPath path + return nil, msgs.getNamespacedPath.badPath\format path, msg unless path + + return path From ad043722c27f99882bcbda7fad3423f84f122766 Mon Sep 17 00:00:00 2001 From: line0 Date: Wed, 27 May 2026 00:47:41 +0200 Subject: [PATCH 20/26] refactor port new ConfigHandler/ConfigView from sqlite branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scripts sharing a config file (e.g. writing to l0.DependencyControl.json) now coordinate correctly: each writer reads the current file state before merging its own section, so concurrent writes no longer risk clobbering each other's data. The concertWrite flag on write() is no longer needed and has been removed. -getConfigHandler() returns a ConfigView instead of a ConfigHandler. The interface is identical (.c, .config, load!, write!) so existing scripts are unaffected. The preferred constructor going forward is ConfigView.get(file, section, defaults) rather than ConfigHandler(file, defaults, section) directly. - feat: new Enum class - compared to a naked table this is immutable, errors when an undefined key is accessed and comes with utility functions like reverse lookup (value → key name) and membership testing --- modules/DependencyControl/ConfigHandler.moon | 716 ++++++++++--------- modules/DependencyControl/ConfigView.moon | 228 ++++++ modules/DependencyControl/Enum.moon | 127 ++++ modules/DependencyControl/FileOps.moon | 8 +- modules/DependencyControl/Lock.moon | 140 ++++ modules/DependencyControl/Record.moon | 20 +- 6 files changed, 889 insertions(+), 350 deletions(-) create mode 100644 modules/DependencyControl/ConfigView.moon create mode 100644 modules/DependencyControl/Enum.moon create mode 100644 modules/DependencyControl/Lock.moon diff --git a/modules/DependencyControl/ConfigHandler.moon b/modules/DependencyControl/ConfigHandler.moon index 7d42a43..e554bb7 100644 --- a/modules/DependencyControl/ConfigHandler.moon +++ b/modules/DependencyControl/ConfigHandler.moon @@ -1,390 +1,434 @@ -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" + } } - --- Creates a configuration handler for a JSON file and optional nested section. - -- @param file string - -- @param[opt] defaults table - -- @param[opt] section string|string[] - -- @param[opt] noLoad boolean + -- make references to provided handlers weak to allow for gc + @handlers = setmetatable {}, {__mode: 'v'} + @logger = Logger fileBaseName: "DepCtrl.ConfigHandler", fileSubName: script_namespace + + --- Returns an existing handler for filePath, or creates and optionally loads one. + -- @param filePath string -- @param[opt] logger Logger - 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 - - --- Registers and validates the target config file path for this handler. - -- @param path string - -- @return boolean|nil + -- @param[opt=false] noLoad boolean + -- @return ConfigHandler|nil -- @return string|nil err - 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 + @get = (filePath, logger = @logger, noLoad = false) => + return handler for path, handler in pairs @@handlers when path == filePath - --- Unregisters this handler from the shared file-handler registry. - -- @return boolean - 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 + path, msg = fileOps.validateFullPath filePath, true + return nil, msgs.new.badPath\format filePath, msg unless path - --- Reads and decodes a JSON config file. - -- @param[opt] file string - -- @param[opt=true] useLock boolean - -- @param[opt] waitLockTime number - -- @return table|boolean|nil + 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 - readFile: (file = @file, useLock = true, waitLockTime) => - if useLock - time, err = @getLock waitLockTime - unless time - -- handle\close! - return false, errors.failedLock\format "reading", err + @getView = (filePath, hivePath, defaults, logger) => + handler, msg = @get filePath, logger + return nil, msgs.getView.failedHandler\format filePath, msg unless handler - mode, file = fileOps.attributes file, "mode" + return handler\getView hivePath, defaults + + + --- 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 - @releaseLock! if useLock - return false, errors.configCorrupted\format backup + return res + + 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 - return result - --- Loads this handler's configured section into the local user configuration table. - -- @return boolean - -- @return string|nil err - load: => - return false, errors.noFile unless @file + hasNonPrivateFields = (tbl) -> + for k, _ in pairs tbl + if k\sub(1, 1) == "_" + continue + else return true + + return false + + + 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 - config, err = @readFile! - return config, err unless config + hive = {} + recurse path, hive, 1, config + return hive - 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 + return false + when "table" + config = config[key] + else + return nil, msgs.traverseHive.badKey\format i, key, type config - @userConfig or= {} - @userConfig[k] = v for k,v in pairs config - return sectionExists + return config or false - --- Merges this handler's section into an in-memory root config table. - -- @param config table - -- @return table|boolean - -- @return string|nil err - 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 - - --- Deletes this handler's section from disk by clearing user config and writing. - -- @param[opt] concertWrite boolean - -- @param[opt] waitLockTime number - -- @return boolean - -- @return string|nil err - delete: (concertWrite, waitLockTime) => - @userConfig = nil - return @write concertWrite, waitLockTime - --- Writes current config state to disk, optionally coordinating all handlers sharing the same file. - -- @param[opt] concertWrite boolean - -- @param[opt] waitLockTime number - -- @return boolean - -- @return string|nil err - write: (concertWrite, waitLockTime) => - return false, errors.noFile unless @file + 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 - -- get a lock to avoid concurrent config file access - time, err = @getLock waitLockTime - unless time - return false, errors.failedLock\format "writing", err + key = path[depth] - -- 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= {} + if depth == #path + target[key] = source[key] + return true - -- 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 + if target[key] != nil and "table" != type target[key] + return nil, msgs.mergeHive.badKey\format depth, key, type target[key] - -- write the whole config file in one go - handle, err = io.open(@file, "w") - unless handle - @releaseLock! - return false, err + target[key] or= {} + return mergeHive path, source[key], target[key], depth + 1 - @logger\trace traceMsgs.writing, @file - handle\setvbuf "full", 10e6 - handle\write res - handle\flush! - handle\close! - @releaseLock! + + 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 - --- Acquires the global config mutex lock. - -- @param[opt=5000] waitTimeout number - -- @param[opt=50] checkInterval number - -- @return number|boolean timePassedOrFalse - -- @return string|nil err - 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 - - --- Creates a child handler bound to a subsection of the same config file. - -- @param section string|string[] - -- @param[opt] defaults table - -- @param[opt] noLoad boolean - -- @return ConfigHandler - getSectionHandler: (section, defaults, noLoad) => - return @@ @file, defaults, section, noLoad, @logger - - --- Releases the global config mutex lock. - -- @param[opt] force boolean - -- @return boolean - -- @return string|nil err - releaseLock: (force) => - if @hasLock or force - @hasLock = false - mutex.unlock! - return true - return false, errors.noLock - --- Deep-copies a table while skipping private keys prefixed with "_". - -- @param tbl table - -- @return table + cleanHive = (path, config) -> + hive, msg = traverseHive path, config + return hive, msg if hive == nil + + 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 - - --- Imports values into this handler's user configuration. - -- @param[opt] tbl table|ConfigHandler - -- @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 - @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 + - return changesMade + --- 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= {} + + -- 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..42d7215 --- /dev/null +++ b/modules/DependencyControl/ConfigView.moon @@ -0,0 +1,228 @@ +util = require "aegisub.util" +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 = util.copy @defaults + merged[k] = v for k, v in pairs @userConfig + return next, merged + } + @c = @config -- shortcut + + + setDefaults = (defaults) => + @defaults = defaults and util.deep_copy(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 {__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[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 + + + --- 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/Enum.moon b/modules/DependencyControl/Enum.moon new file mode 100644 index 0000000..242ce67 --- /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 + + 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 + return nil, msgs.validate.invalidValue\format argName != nil and msgs.validate.argPrefix or "", + value, @name + + return true diff --git a/modules/DependencyControl/FileOps.moon b/modules/DependencyControl/FileOps.moon index de87a77..5fd1ead 100644 --- a/modules/DependencyControl/FileOps.moon +++ b/modules/DependencyControl/FileOps.moon @@ -4,7 +4,7 @@ lfs = require "lfs" Logger = require "l0.DependencyControl.Logger" Common = require "l0.DependencyControl.Common" -local ConfigHandler +local ConfigView --- Filesystem utility helpers used by DependencyControl. -- @class FileOps @@ -90,10 +90,10 @@ class FileOps createConfig = (noLoad, configDir) -> FileOps.configDir = configDir if configDir - ConfigHandler or= require "l0.DependencyControl.ConfigHandler" + ConfigView or= require "l0.DependencyControl.ConfigView" unless FileOps.config - FileOps.config = ConfigHandler "#{FileOps.configDir}/l0.#{FileOps.__name}.json", - {toRemove: {}}, nil, noLoad, FileOps.logger + 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 diff --git a/modules/DependencyControl/Lock.moon b/modules/DependencyControl/Lock.moon new file mode 100644 index 0000000..4566e63 --- /dev/null +++ b/modules/DependencyControl/Lock.moon @@ -0,0 +1,140 @@ +mutex = require "BM.BadMutex" +PreciseTimer = require "PT.PreciseTimer" + +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, @resource, @holderName, @logger, @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 + PreciseTimer.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/Record.moon b/modules/DependencyControl/Record.moon index b2d8b28..b337784 100644 --- a/modules/DependencyControl/Record.moon +++ b/modules/DependencyControl/Record.moon @@ -4,7 +4,7 @@ re = require "aegisub.re" Common = require "l0.DependencyControl.Common" Logger = require "l0.DependencyControl.Logger" -ConfigHandler = require "l0.DependencyControl.ConfigHandler" +ConfigView = require "l0.DependencyControl.ConfigView" FileOps = require "l0.DependencyControl.FileOps" Updater = require "l0.DependencyControl.Updater" ModuleLoader = require "l0.DependencyControl.ModuleLoader" @@ -140,19 +140,19 @@ class Record extends Common checkOptionalModules: ModuleLoader.checkOptionalModules --- Loads global DependencyControl configuration. - -- @return ConfigHandler + -- @return ConfigView @loadConfig = => if @config @config\load! - else @config = ConfigHandler @depConf.file, @depConf.globalDefaults, {"config"}, nil, @logger + 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= ConfigHandler not @virtual and @@depConf.file, {}, - { @@ScriptType.name.legacy[@scriptType], @namespace }, true, @@logger + @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 @@ -187,7 +187,7 @@ class Record extends Common @@logger\trace msgs.writeConfig.writing, @@terms.scriptType.singular[@scriptType] @config\import @, @@depConf.scriptFields, false, true - success, errMsg = @config\write false + success, errMsg = @config\save! assert success, msgs.writeConfig.error\format errMsg @@ -203,13 +203,13 @@ class Record extends Common getConfigFileName: () => return aegisub.decode_path "#{@@configDir}/#{@configFile}" - --- Creates a ConfigHandler for this record's script-specific config file. + --- 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 ConfigHandler + -- @return ConfigView getConfigHandler: (defaults, section, noLoad) => - return ConfigHandler @getConfigFileName!, defaults, section, noLoad + return ConfigView.get @getConfigFileName!, section, defaults, nil, noLoad --- Creates a logger preconfigured for this record. -- @param[opt] args table @@ -235,7 +235,7 @@ class Record extends Common --- Retrieves managed submodules registered under this module namespace. -- @return string[]|nil - -- @return ConfigHandler|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] From a3106c9be2f8e8f5e1f574737f869581b8b475bd Mon Sep 17 00:00:00 2001 From: line0 Date: Wed, 27 May 2026 10:11:50 +0200 Subject: [PATCH 21/26] refactor: add unit tests and extend testing framework (WIP) --- modules/DependencyControl/Common.moon | 121 ++ modules/DependencyControl/ConfigHandler.moon | 1 + modules/DependencyControl/ConfigView.moon | 14 +- modules/DependencyControl/Enum.moon | 6 +- modules/DependencyControl/FileOps.moon | 74 +- modules/DependencyControl/Lock.moon | 2 +- modules/DependencyControl/ModuleLoader.moon | 3 +- modules/DependencyControl/Record.moon | 17 +- .../DependencyControl/ScriptUpdateRecord.moon | 150 ++ modules/DependencyControl/Stub.moon | 145 ++ modules/DependencyControl/Tests.moon | 1700 +++++++++++++++++ modules/DependencyControl/UnitTestSuite.moon | 152 +- modules/DependencyControl/UpdateFeed.moon | 97 +- modules/DependencyControl/Updater.moon | 3 +- 14 files changed, 2235 insertions(+), 250 deletions(-) create mode 100644 modules/DependencyControl/ScriptUpdateRecord.moon create mode 100644 modules/DependencyControl/Stub.moon diff --git a/modules/DependencyControl/Common.moon b/modules/DependencyControl/Common.moon index 52a059a..b3a27dd 100644 --- a/modules/DependencyControl/Common.moon +++ b/modules/DependencyControl/Common.moon @@ -1,6 +1,106 @@ ffi = require "ffi" re = require "aegisub.re" +-- 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 @@ -12,6 +112,8 @@ class DependencyControlCommon -- Some terms are shared across components @platform = "#{ffi.os}-#{ffi.arch}" + @moduleName = "l0.DependencyControl" + @terms = { scriptType: { singular: { "automation script", "module" } @@ -59,3 +161,22 @@ class DependencyControlCommon @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 diff --git a/modules/DependencyControl/ConfigHandler.moon b/modules/DependencyControl/ConfigHandler.moon index e554bb7..c922fd4 100644 --- a/modules/DependencyControl/ConfigHandler.moon +++ b/modules/DependencyControl/ConfigHandler.moon @@ -266,6 +266,7 @@ Reload your automation scripts to generate a new configuration file.]] 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 diff --git a/modules/DependencyControl/ConfigView.moon b/modules/DependencyControl/ConfigView.moon index 42d7215..52a52c5 100644 --- a/modules/DependencyControl/ConfigView.moon +++ b/modules/DependencyControl/ConfigView.moon @@ -83,30 +83,30 @@ class ConfigView 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}, { + 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.__tbl + __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.__key + 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.__key] = util.deep_copy @defaults[tbl.__key].__tbl + @userConfig[tbl.__targetMethodKey] = util.deep_copy @defaults[tbl.__targetMethodKey].__targetTable -- finally perform requested write on userdata - tbl = @userConfig[tbl.__key] + tbl = @userConfig[tbl.__targetMethodKey] for i = #upKeys-1, 1, -1 tbl = tbl[upKeys[i]] tbl[k] = v - __pairs: (tbl) -> return next, tbl.__tbl + __pairs: (tbl) -> return next, tbl.__targetTable __ipairs: (tbl) -> - i, n, orgTbl = 0, #tbl.__tbl, tbl.__tbl + i, n, orgTbl = 0, #tbl.__targetTable, tbl.__targetTable -> i += 1 return i, orgTbl[i] if i <= n diff --git a/modules/DependencyControl/Enum.moon b/modules/DependencyControl/Enum.moon index 242ce67..8f8da85 100644 --- a/modules/DependencyControl/Enum.moon +++ b/modules/DependencyControl/Enum.moon @@ -103,7 +103,7 @@ class Enum return key if "table" != type values - return nil, msgs.describe.valueNotDefined + return nil, msgs.describe.valueNotDefined\format values, @name keys = for v in *values key = @__valuesToKeys[v] @@ -121,7 +121,7 @@ class Enum -- @return string|nil err validate: (value, argName) => if value == nil or @__valuesToKeys[value] == nil - return nil, msgs.validate.invalidValue\format argName != nil and msgs.validate.argPrefix or "", - value, @name + 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 5fd1ead..9140a5c 100644 --- a/modules/DependencyControl/FileOps.moon +++ b/modules/DependencyControl/FileOps.moon @@ -1,7 +1,6 @@ ffi = require "ffi" re = require "aegisub.re" lfs = require "lfs" - Logger = require "l0.DependencyControl.Logger" Common = require "l0.DependencyControl.Common" local ConfigView @@ -21,6 +20,9 @@ class FileOps 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." @@ -87,18 +89,33 @@ class FileOps maxLen: 255 } @logger = Logger! + @pathSep = pathMatch.sep createConfig = (noLoad, configDir) -> FileOps.configDir = configDir if configDir - ConfigView or= require "l0.DependencyControl.ConfigView" + ConfigView or= require "#{Common.moduleName}.ConfigView" unless FileOps.config - FileOps.config = ConfigView.get "#{FileOps.configDir}/l0.#{FileOps.__name}.json", + 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[] + -- @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 @@ -213,6 +230,13 @@ 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 --- Moves a file to a target path, optionally replacing existing targets. -- @param source string @@ -273,9 +297,9 @@ class FileOps return true --- Reads and returns the full contents of a file. - -- @param path string - -- @return string|nil data - -- @return string|nil err + -- @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 @@ -311,6 +335,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 @@ -328,6 +357,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 @@ -345,7 +382,7 @@ class FileOps return attr, fullPath, dev, dir, file --- Validates and normalizes an absolute filesystem path. - -- @param path string + -- @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 @@ -353,7 +390,9 @@ class FileOps -- @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 @@ -391,9 +430,9 @@ class FileOps --- 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 + -- @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 + -- @param ext string file extension (including dot) -- @param[opt=true] nested boolean -- @return string|nil path -- @return string|nil err @@ -401,11 +440,12 @@ class FileOps res, msg = Common.validateNamespace namespace return nil, msg unless res - res, msg = FileOps.validateFullPath basePath - return nil, msgs.getNamespacedPath.badBasePath\format basePath, msg unless res + fullBasePath, msg = FileOps.validateFullPath basePath + return nil, msgs.getNamespacedPath.badBasePath\format basePath, msg unless fullBasePath - path = "#{basePath}/#{nested and namespace\gsub("%.", "/") or namespace}#{ext}" - path, msg = FileOps.validateFullPath path - return nil, msgs.getNamespacedPath.badPath\format path, msg unless path + 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 path + return normalizedFullPath diff --git a/modules/DependencyControl/Lock.moon b/modules/DependencyControl/Lock.moon index 4566e63..b128258 100644 --- a/modules/DependencyControl/Lock.moon +++ b/modules/DependencyControl/Lock.moon @@ -54,7 +54,7 @@ class Lock --- Creates a lock for the given resource. -- @param args table new: (args) => - {@namespace, @resource, @holderName, @logger, @expiresAfter} = 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 diff --git a/modules/DependencyControl/ModuleLoader.moon b/modules/DependencyControl/ModuleLoader.moon index 16db7a9..fc9bc20 100644 --- a/modules/DependencyControl/ModuleLoader.moon +++ b/modules/DependencyControl/ModuleLoader.moon @@ -82,7 +82,7 @@ class ModuleLoader unless loaded LOADED_MODULES[moduleName] = nil res or= "unknown error" - ._missing = res\match "module '.+' not found:" + ._missing = nil != res\find "module '#{moduleName}' not found:", nil, true ._error = res unless ._missing return nil @@ -167,6 +167,7 @@ class ModuleLoader 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") diff --git a/modules/DependencyControl/Record.moon b/modules/DependencyControl/Record.moon index b337784..c7d8145 100644 --- a/modules/DependencyControl/Record.moon +++ b/modules/DependencyControl/Record.moon @@ -13,8 +13,6 @@ SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" --- DependencyControl record representing one managed or unmanaged script/module. -- @class Record class Record extends Common - namespaceValidation = re.compile "^(?:[-\\w]+\\.)+[-\\w]+$" - msgs = { new: { badRecordError: "Error: Bad #{@@__name} record (%s)." @@ -144,14 +142,14 @@ class Record extends Common @loadConfig = => if @config @config\load! - else @config = ConfigView.get @depConf.file, {"config"}, @depConf.globalDefaults, @logger + 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, + @config or= ConfigView\get not @virtual and @@depConf.file, { @@ScriptType.name.legacy[@scriptType], @namespace }, {}, @@logger, true -- import and overwrites version record from the configuration @@ -209,7 +207,7 @@ class Record extends Common -- @param[opt] noLoad boolean -- @return ConfigView getConfigHandler: (defaults, section, noLoad) => - return ConfigView.get @getConfigFileName!, section, defaults, nil, noLoad + return ConfigView\get @getConfigFileName!, section, defaults, nil, noLoad --- Creates a logger preconfigured for this record. -- @param[opt] args table @@ -331,12 +329,11 @@ class Record extends Common return version else return nil, err - --- Validates a dependency namespace according to DependencyControl rules. - -- @param[opt] namespace string - -- @param[opt] isVirtual boolean + --- Validates this record's namespace, always passing for virtual records. -- @return boolean - validateNamespace: (namespace = @namespace, isVirtual = @virtual) => - return isVirtual or namespaceValidation\match @namespace + 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 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/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/Tests.moon b/modules/DependencyControl/Tests.moon index af0ff3e..d9ff112 100644 --- a/modules/DependencyControl/Tests.moon +++ b/modules/DependencyControl/Tests.moon @@ -1,11 +1,1711 @@ 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" + + BADMUTEX_MODULE_NAME = "BM.BadMutex" + PRECISETIMER_MODULE_NAME = "PT.PreciseTimer" + 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}" + { 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 + + -- 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", + "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 BADMUTEX_MODULE_NAME, "tryLock")\returns true + ut\stub BADMUTEX_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 BADMUTEX_MODULE_NAME, "tryLock")\returns true + ut\stub BADMUTEX_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 BADMUTEX_MODULE_NAME, "tryLock")\returns true + ut\stub BADMUTEX_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 BADMUTEX_MODULE_NAME, "tryLock")\returns false + sleepStub = ut\stub PRECISETIMER_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 BADMUTEX_MODULE_NAME, "tryLock")\calls -> + callCount += 1 + callCount >= 2 -- fails first, succeeds second + sleepStub = ut\stub PRECISETIMER_MODULE_NAME, "sleep" + ut\stub BADMUTEX_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 BADMUTEX_MODULE_NAME, "tryLock")\returns true + ut\stub BADMUTEX_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 BADMUTEX_MODULE_NAME, "tryLock")\returns false + ut\stub PRECISETIMER_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 BADMUTEX_MODULE_NAME, "tryLock")\returns true + unlockStub = ut\stub BADMUTEX_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 BADMUTEX_MODULE_NAME, "tryLock")\returns true + unlockStub = ut\stub BADMUTEX_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/UnitTestSuite.moon b/modules/DependencyControl/UnitTestSuite.moon index 9d4eb3a..e8ebead 100644 --- a/modules/DependencyControl/UnitTestSuite.moon +++ b/modules/DependencyControl/UnitTestSuite.moon @@ -4,6 +4,9 @@ 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 +52,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 +84,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 +147,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 +164,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 +243,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 @@ -754,6 +675,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. @@ -761,7 +683,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. diff --git a/modules/DependencyControl/UpdateFeed.moon b/modules/DependencyControl/UpdateFeed.moon index 1d2fe55..0bd73f5 100644 --- a/modules/DependencyControl/UpdateFeed.moon +++ b/modules/DependencyControl/UpdateFeed.moon @@ -1,105 +1,14 @@ json = require "json" DownloadManager = require "DM.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" ---- Feed-specific update information for a single script in a selected channel. --- @class 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" - } - } - - --- Creates an update record for a single script entry in a feed. - -- @param namespace string - -- @param data table - -- @param[opt] config table - -- @param scriptType number - -- @param[opt=true] autoChannel boolean - -- @param[opt] logger Logger - 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 - - - --- Returns all available channel names for this script and the default channel. - -- @return string[] channels - -- @return string|nil 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 channel fields on this record. - -- @param[opt] channelName string - -- @return boolean - -- @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 == @@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})[@@platform], @@platform - - --- Formats changelog entries between the current and a minimum version as a string. - -- @param versionRecord any - -- @param[opt=0] minVer number|string - -- @return string changelog - 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" - - --- Downloaded and expanded update feed data source. +--- Downloaded and expanded update feed data source. -- @class UpdateFeed class UpdateFeed extends Common templateData = { @@ -164,8 +73,6 @@ class UpdateFeed extends Common -- @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 diff --git a/modules/DependencyControl/Updater.moon b/modules/DependencyControl/Updater.moon index c2746a2..28fb10f 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/DependencyControl/Updater.moon @@ -318,8 +318,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 From daa023a5d9720dd9406cd95c1e1ce1954497bf3b Mon Sep 17 00:00:00 2001 From: line0 Date: Thu, 28 May 2026 22:59:50 +0200 Subject: [PATCH 22/26] refactor: remove dependency on aegisub.re and aegisub.util this is so we can run tests w/o Aegisub in CI/CLI --- modules/DependencyControl/Common.moon | 28 +++++++++++--- modules/DependencyControl/ConfigView.moon | 8 ++-- modules/DependencyControl/FileOps.moon | 40 +++++++++++++------- modules/DependencyControl/Record.moon | 1 - modules/DependencyControl/UnitTestSuite.moon | 39 +++++++------------ 5 files changed, 67 insertions(+), 49 deletions(-) diff --git a/modules/DependencyControl/Common.moon b/modules/DependencyControl/Common.moon index b3a27dd..48ed94a 100644 --- a/modules/DependencyControl/Common.moon +++ b/modules/DependencyControl/Common.moon @@ -1,5 +1,4 @@ ffi = require "ffi" -re = require "aegisub.re" -- Compares two values for deep equality. Tables are compared recursively; -- other types use == except that two identical values always compare equal. @@ -143,16 +142,16 @@ class DependencyControlCommon } } - namespaceValidation = re.compile "^(?:[-\\w]+\\.)+[-\\w]+$" - --- Validates a DependencyControl namespace string. -- @param namespace string -- @return boolean|nil -- @return string|nil err @validateNamespace = (namespace) -> - return if namespaceValidation\match namespace - true - else false, msgs.validateNamespace.badNamespace\format 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"), @@ -180,3 +179,20 @@ class DependencyControlCommon -- @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/ConfigView.moon b/modules/DependencyControl/ConfigView.moon index 52a52c5..3b4f926 100644 --- a/modules/DependencyControl/ConfigView.moon +++ b/modules/DependencyControl/ConfigView.moon @@ -1,4 +1,4 @@ -util = require "aegisub.util" +Common = require "l0.DependencyControl.Common" local ConfigHandler --- A view into a hive (nested path) of a ConfigHandler's JSON config file. @@ -67,7 +67,7 @@ class ConfigView __len: (tbl) -> return 0 __ipairs: (tbl) -> error "numerically indexed config hive keys are not supported" __pairs: (tbl) -> - merged = util.copy @defaults + merged = Common.copy @defaults merged[k] = v for k, v in pairs @userConfig return next, merged } @@ -75,7 +75,7 @@ class ConfigView setDefaults = (defaults) => - @defaults = defaults and util.deep_copy(defaults) or {} + @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) -> @@ -98,7 +98,7 @@ class ConfigView -- 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] = util.deep_copy @defaults[tbl.__targetMethodKey].__targetTable + @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 diff --git a/modules/DependencyControl/FileOps.moon b/modules/DependencyControl/FileOps.moon index 9140a5c..218726e 100644 --- a/modules/DependencyControl/FileOps.moon +++ b/modules/DependencyControl/FileOps.moon @@ -1,5 +1,4 @@ ffi = require "ffi" -re = require "aegisub.re" lfs = require "lfs" Logger = require "l0.DependencyControl.Logger" Common = require "l0.DependencyControl.Common" @@ -80,12 +79,14 @@ 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 } @logger = Logger! @@ -238,6 +239,13 @@ class FileOps 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 @@ -408,24 +416,30 @@ 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}) - + 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. diff --git a/modules/DependencyControl/Record.moon b/modules/DependencyControl/Record.moon index c7d8145..070fc04 100644 --- a/modules/DependencyControl/Record.moon +++ b/modules/DependencyControl/Record.moon @@ -1,6 +1,5 @@ json = require "json" lfs = require "lfs" -re = require "aegisub.re" Common = require "l0.DependencyControl.Common" Logger = require "l0.DependencyControl.Logger" diff --git a/modules/DependencyControl/UnitTestSuite.moon b/modules/DependencyControl/UnitTestSuite.moon index e8ebead..8eb63ee 100644 --- a/modules/DependencyControl/UnitTestSuite.moon +++ b/modules/DependencyControl/UnitTestSuite.moon @@ -1,6 +1,5 @@ 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;" @@ -475,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. @@ -522,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 From 228cf7c684443bad3ae3b840355d2f2a72260aad Mon Sep 17 00:00:00 2001 From: line0 Date: Fri, 29 May 2026 00:36:08 +0200 Subject: [PATCH 23/26] refactor: lift dependency on PreciseTimer --- modules/DependencyControl.moon | 1 - modules/DependencyControl/Lock.moon | 5 +- modules/DependencyControl/Logger.moon | 4 +- modules/DependencyControl/Tests.moon | 53 ++++++++++++++++++-- modules/DependencyControl/Timer.moon | 68 ++++++++++++++++++++++++++ modules/DependencyControl/Updater.moon | 5 +- 6 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 modules/DependencyControl/Timer.moon diff --git a/modules/DependencyControl.moon b/modules/DependencyControl.moon index 09f6696..b150cab 100644 --- a/modules/DependencyControl.moon +++ b/modules/DependencyControl.moon @@ -38,7 +38,6 @@ rec = DependencyControl{ { {"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"}, } } diff --git a/modules/DependencyControl/Lock.moon b/modules/DependencyControl/Lock.moon index b128258..40d56de 100644 --- a/modules/DependencyControl/Lock.moon +++ b/modules/DependencyControl/Lock.moon @@ -1,6 +1,5 @@ mutex = require "BM.BadMutex" -PreciseTimer = require "PT.PreciseTimer" - +Timer = require "l0.DependencyControl.Timer" Logger = require "l0.DependencyControl.Logger" Enum = require "l0.DependencyControl.Enum" @@ -116,7 +115,7 @@ class Lock return @@LockState.Held, timePassed @logger\trace msgs.lock.heldByOther, @namespace, @resource, lockWaitInterval - PreciseTimer.sleep lockWaitInterval unless timeout == 0 + Timer.sleep lockWaitInterval unless timeout == 0 timePassed += lockWaitInterval @logger\trace msgs.lock.timeout, @namespace, @resource, @holderName, @instanceId diff --git a/modules/DependencyControl/Logger.moon b/modules/DependencyControl/Logger.moon index fac7c4b..96038d4 100644 --- a/modules/DependencyControl/Logger.moon +++ b/modules/DependencyControl/Logger.moon @@ -1,4 +1,4 @@ -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. @@ -20,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 diff --git a/modules/DependencyControl/Tests.moon b/modules/DependencyControl/Tests.moon index d9ff112..92c35bd 100644 --- a/modules/DependencyControl/Tests.moon +++ b/modules/DependencyControl/Tests.moon @@ -15,9 +15,10 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> Record = require "l0.DependencyControl.Record" UpdateFeed = require "l0.DependencyControl.UpdateFeed" ScriptUpdateRecord = require "l0.DependencyControl.ScriptUpdateRecord" + Timer = require "l0.DependencyControl.Timer" BADMUTEX_MODULE_NAME = "BM.BadMutex" - PRECISETIMER_MODULE_NAME = "PT.PreciseTimer" + TIMER_MODULE_NAME = "l0.DependencyControl.Timer" FILEOPS_MODULE_NAME = "l0.DependencyControl.FileOps" JSON_MODULE_NAME = "json" @@ -26,6 +27,50 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> basePath = aegisub.decode_path "?temp/l0.#{DependencyControl.__name}.#{DependencyControl.UnitTestSuite.__name}_#{'%04X'\format math.random 0, 16^4-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" + } + } + Common: { _description: "Tests for the Common base class providing shared utilities and enums across DependencyControl components." @@ -684,7 +729,7 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> lock_timeout: (ut) -> tryLockStub = (ut\stub BADMUTEX_MODULE_NAME, "tryLock")\returns false - sleepStub = ut\stub PRECISETIMER_MODULE_NAME, "sleep" + sleepStub = ut\stub TIMER_MODULE_NAME, "sleep" ut\stub Lock.logger, "trace" lock = Lock namespace: "ns", resource: "res" state, timePassed = lock\lock 0 @@ -697,7 +742,7 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> tryLockStub = (ut\stub BADMUTEX_MODULE_NAME, "tryLock")\calls -> callCount += 1 callCount >= 2 -- fails first, succeeds second - sleepStub = ut\stub PRECISETIMER_MODULE_NAME, "sleep" + sleepStub = ut\stub TIMER_MODULE_NAME, "sleep" ut\stub BADMUTEX_MODULE_NAME, "unlock" ut\stub Lock.logger, "trace" lock = Lock namespace: "ns", resource: "res" @@ -721,7 +766,7 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> tryLock_fail: (ut) -> tryLockStub = (ut\stub BADMUTEX_MODULE_NAME, "tryLock")\returns false - ut\stub PRECISETIMER_MODULE_NAME, "sleep" + ut\stub TIMER_MODULE_NAME, "sleep" ut\stub Lock.logger, "trace" lock = Lock namespace: "ns", resource: "res" state, timePassed = lock\tryLock! 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/Updater.moon b/modules/DependencyControl/Updater.moon index 28fb10f..5d6329a 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/DependencyControl/Updater.moon @@ -1,7 +1,6 @@ lfs = require "lfs" DownloadManager = require "DM.DownloadManager" -PreciseTimer = require "PT.PreciseTimer" - +Timer = require "l0.DependencyControl.Timer" UpdateFeed = require "l0.DependencyControl.UpdateFeed" fileOps = require "l0.DependencyControl.FileOps" Logger = require "l0.DependencyControl.Logger" @@ -563,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 From fc48086c6bf2af82120dab474f1a1220c464c529 Mon Sep 17 00:00:00 2001 From: line0 Date: Fri, 29 May 2026 01:32:08 +0200 Subject: [PATCH 24/26] feat: provide an alternative to BadMutex that only uses OS APIs experimental, only used when BadMutex is unavailable or DEPCTRL_PREFER_FFI_MUTEX=1 --- modules/AegisubShims.moon | 3 + modules/DependencyControl.moon | 4 +- modules/DependencyControl/Lock.moon | 2 +- modules/DependencyControl/TerribleMutex.moon | 87 ++++++++++++++++++++ modules/DependencyControl/Tests.moon | 84 +++++++++++++++---- 5 files changed, 159 insertions(+), 21 deletions(-) create mode 100644 modules/AegisubShims.moon create mode 100644 modules/DependencyControl/TerribleMutex.moon 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 b150cab..20738e2 100644 --- a/modules/DependencyControl.moon +++ b/modules/DependencyControl.moon @@ -37,8 +37,8 @@ rec = 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"}, - {"requireffi.requireffi", version: "0.1.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", 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 diff --git a/modules/DependencyControl/Lock.moon b/modules/DependencyControl/Lock.moon index 40d56de..94da0bb 100644 --- a/modules/DependencyControl/Lock.moon +++ b/modules/DependencyControl/Lock.moon @@ -1,4 +1,4 @@ -mutex = require "BM.BadMutex" +mutex = require "l0.DependencyControl.TerribleMutex" Timer = require "l0.DependencyControl.Timer" Logger = require "l0.DependencyControl.Logger" Enum = require "l0.DependencyControl.Enum" 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 index 92c35bd..b295853 100644 --- a/modules/DependencyControl/Tests.moon +++ b/modules/DependencyControl/Tests.moon @@ -16,9 +16,10 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> UpdateFeed = require "l0.DependencyControl.UpdateFeed" ScriptUpdateRecord = require "l0.DependencyControl.ScriptUpdateRecord" Timer = require "l0.DependencyControl.Timer" + TerribleMutex = require "l0.DependencyControl.TerribleMutex" - BADMUTEX_MODULE_NAME = "BM.BadMutex" - TIMER_MODULE_NAME = "l0.DependencyControl.Timer" + TERRIBLE_MUTEX_MODULE_NAME = "l0.DependencyControl.TerribleMutex" + TIMER_MODULE_NAME = "l0.DependencyControl.Timer" FILEOPS_MODULE_NAME = "l0.DependencyControl.FileOps" JSON_MODULE_NAME = "json" @@ -71,6 +72,53 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> } } + 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" + } + } + Common: { _description: "Tests for the Common base class providing shared utilities and enums across DependencyControl components." @@ -695,8 +743,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> ut\assertEquals lock\getState!, Lock.LockState.Unknown getState_held: (ut) -> - (ut\stub BADMUTEX_MODULE_NAME, "tryLock")\returns true - ut\stub BADMUTEX_MODULE_NAME, "unlock" + (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! @@ -706,8 +754,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> -- lock lock_success: (ut) -> - tryLockStub = (ut\stub BADMUTEX_MODULE_NAME, "tryLock")\returns true - ut\stub BADMUTEX_MODULE_NAME, "unlock" + 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! @@ -717,8 +765,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> lock\release! lock_alreadyHeld: (ut) -> - tryLockStub = (ut\stub BADMUTEX_MODULE_NAME, "tryLock")\returns true - ut\stub BADMUTEX_MODULE_NAME, "unlock" + 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 @@ -728,7 +776,7 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> lock\release! lock_timeout: (ut) -> - tryLockStub = (ut\stub BADMUTEX_MODULE_NAME, "tryLock")\returns false + 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" @@ -739,11 +787,11 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> lock_retry: (ut) -> callCount = 0 - tryLockStub = (ut\stub BADMUTEX_MODULE_NAME, "tryLock")\calls -> + 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 BADMUTEX_MODULE_NAME, "unlock" + ut\stub TERRIBLE_MUTEX_MODULE_NAME, "unlock" ut\stub Lock.logger, "trace" lock = Lock namespace: "ns", resource: "res" state, timePassed = lock\lock! @@ -755,8 +803,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> -- tryLock tryLock_success: (ut) -> - tryLockStub = (ut\stub BADMUTEX_MODULE_NAME, "tryLock")\returns true - ut\stub BADMUTEX_MODULE_NAME, "unlock" + 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! @@ -765,7 +813,7 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> lock\release! tryLock_fail: (ut) -> - tryLockStub = (ut\stub BADMUTEX_MODULE_NAME, "tryLock")\returns false + 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" @@ -776,8 +824,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> -- release release_held: (ut) -> - (ut\stub BADMUTEX_MODULE_NAME, "tryLock")\returns true - unlockStub = ut\stub BADMUTEX_MODULE_NAME, "unlock" + (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! @@ -797,8 +845,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> -- GC canary: unreleased lock is cleaned up and warns on collection gc_canary: (ut) -> - (ut\stub BADMUTEX_MODULE_NAME, "tryLock")\returns true - unlockStub = ut\stub BADMUTEX_MODULE_NAME, "unlock" + (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 From 29bca61ba1847d24858a9487b4d4bff29ece71c3 Mon Sep 17 00:00:00 2001 From: line0 Date: Fri, 29 May 2026 10:24:51 +0200 Subject: [PATCH 25/26] feat: provide an alternative to DownloadManager that uses only OS APIs or installed libcurl --- modules/DependencyControl.moon | 2 +- modules/DependencyControl/Crypto.moon | 180 ++++++ .../DependencyControl/DownloadManager.moon | 96 ++++ modules/DependencyControl/Downloader.moon | 524 ++++++++++++++++++ modules/DependencyControl/FileOps.moon | 35 ++ modules/DependencyControl/Tests.moon | 264 +++++++++ modules/DependencyControl/UpdateFeed.moon | 2 +- modules/DependencyControl/Updater.moon | 2 +- 8 files changed, 1102 insertions(+), 3 deletions(-) create mode 100644 modules/DependencyControl/Crypto.moon create mode 100644 modules/DependencyControl/DownloadManager.moon create mode 100644 modules/DependencyControl/Downloader.moon diff --git a/modules/DependencyControl.moon b/modules/DependencyControl.moon index 20738e2..00b2048 100644 --- a/modules/DependencyControl.moon +++ b/modules/DependencyControl.moon @@ -36,7 +36,7 @@ rec = 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"}, + {"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}, } 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/FileOps.moon b/modules/DependencyControl/FileOps.moon index 218726e..e4f6aa0 100644 --- a/modules/DependencyControl/FileOps.moon +++ b/modules/DependencyControl/FileOps.moon @@ -2,6 +2,8 @@ ffi = require "ffi" lfs = require "lfs" Logger = require "l0.DependencyControl.Logger" Common = require "l0.DependencyControl.Common" +Crypto = require "l0.DependencyControl.Crypto" +Enum = require "l0.DependencyControl.Enum" local ConfigView --- Filesystem utility helpers used by DependencyControl. @@ -52,6 +54,10 @@ class FileOps 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!" } @@ -89,6 +95,10 @@ class FileOps invalidChars: '[<>:"|%?%*%z%c;]' 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 @@ -323,6 +333,31 @@ class FileOps 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" diff --git a/modules/DependencyControl/Tests.moon b/modules/DependencyControl/Tests.moon index b295853..e122bd1 100644 --- a/modules/DependencyControl/Tests.moon +++ b/modules/DependencyControl/Tests.moon @@ -17,6 +17,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> 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" TERRIBLE_MUTEX_MODULE_NAME = "l0.DependencyControl.TerribleMutex" TIMER_MODULE_NAME = "l0.DependencyControl.Timer" @@ -27,6 +29,29 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> 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 + { Timer: { _description: "Tests for the FFI-based Timer: monotonic timing and millisecond sleep." @@ -119,6 +144,211 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> } } + 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" + } + } + + 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." @@ -309,6 +539,38 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> 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) -> @@ -379,6 +641,8 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> "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" diff --git a/modules/DependencyControl/UpdateFeed.moon b/modules/DependencyControl/UpdateFeed.moon index 0bd73f5..d8d79ac 100644 --- a/modules/DependencyControl/UpdateFeed.moon +++ b/modules/DependencyControl/UpdateFeed.moon @@ -1,5 +1,5 @@ json = require "json" -DownloadManager = require "DM.DownloadManager" +DownloadManager = require "l0.DependencyControl.DownloadManager" Logger = require "l0.DependencyControl.Logger" Common = require "l0.DependencyControl.Common" diff --git a/modules/DependencyControl/Updater.moon b/modules/DependencyControl/Updater.moon index 5d6329a..59a12c3 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/DependencyControl/Updater.moon @@ -1,5 +1,5 @@ lfs = require "lfs" -DownloadManager = require "DM.DownloadManager" +DownloadManager = require "l0.DependencyControl.DownloadManager" Timer = require "l0.DependencyControl.Timer" UpdateFeed = require "l0.DependencyControl.UpdateFeed" fileOps = require "l0.DependencyControl.FileOps" From 978d8ca79df2bf4cda9a5f8e309d29c40f7a81c0 Mon Sep 17 00:00:00 2001 From: line0 Date: Sat, 30 May 2026 12:24:08 +0200 Subject: [PATCH 26/26] feat: allow modules to provide import aliases; ship a copy of dkjson to provide "json" if luajson isnt installed --- DependencyControl.json | 29 + README.md | 23 + modules/DependencyControl.moon | 8 + modules/DependencyControl/ModuleProvider.moon | 58 ++ modules/DependencyControl/Record.moon | 11 +- modules/DependencyControl/Tests.moon | 40 + modules/dkjson.moon | 28 + modules/dkjson/vendor/dkjson.lua | 810 ++++++++++++++++++ 8 files changed, 1005 insertions(+), 2 deletions(-) create mode 100644 modules/DependencyControl/ModuleProvider.moon create mode 100644 modules/dkjson.moon create mode 100644 modules/dkjson/vendor/dkjson.lua diff --git a/DependencyControl.json b/DependencyControl.json index 6fe2d04..eaa256b 100644 --- a/DependencyControl.json +++ b/DependencyControl.json @@ -237,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/modules/DependencyControl.moon b/modules/DependencyControl.moon index 00b2048..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" 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 070fc04..bcf61b8 100644 --- a/modules/DependencyControl/Record.moon +++ b/modules/DependencyControl/Record.moon @@ -7,6 +7,7 @@ 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. @@ -35,7 +36,7 @@ class Record extends Common @depConf = { file: aegisub.decode_path "?user/config/l0.#{@@__name}.json", scriptFields: {"author", "configFile", "feed", "moduleName", "name", "namespace", "url", -- REMOVE - "requiredModules", "version", "unmanaged"}, + "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), @@ -72,7 +73,7 @@ class Record extends Common {@requiredModules, moduleName:@moduleName, configFile:configFile, virtual:@virtual, :name, description:@description, url:@url, feed:@feed, recordType:@recordType, :namespace, - author:@author, :version, configFile:@configFile, + author:@author, :version, configFile:@configFile, :provides, :readGlobalScriptVars, :saveRecordToConfig} = args @recordType or= @@RecordType.Managed @@ -127,6 +128,12 @@ class Record extends Common @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 diff --git a/modules/DependencyControl/Tests.moon b/modules/DependencyControl/Tests.moon index e122bd1..7ea04f4 100644 --- a/modules/DependencyControl/Tests.moon +++ b/modules/DependencyControl/Tests.moon @@ -19,6 +19,7 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> 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" @@ -52,6 +53,9 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> 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." @@ -180,6 +184,42 @@ DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> } } + 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.)" 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